diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a284..20b7a0a2ea 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1046,6 +1046,16 @@ impl DocView { self.pages.get(&page).map(|p| p.tex.id()) } + /// Whether `page` is still drawn as it was before its last change: its raster, or on a tiled + /// page any of its tiles, is waiting for a replacement. + pub(crate) fn page_out_of_date(&self, page: usize) -> bool { + if self.tiles.keys().any(|k| k.0 == page) { + self.stale_tiles.iter().any(|k| k.0 == page) + } else { + self.pages.get(&page).is_some_and(|p| p.tag == STALE_TAG) + } + } + /// The print preview's picture of `page`: the sheet raster when it has arrived, otherwise the /// thumbnail. pub(crate) fn page_preview(&self, page: usize) -> Option { diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 66ad98ffe6..5b5a6445f8 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -358,6 +358,9 @@ pub struct ImageSelection { drag: Option<(Pos2, Option)>, /// Crop mode (Acrobat's crop handles): the box kept so far. pub crop: Option, + /// A move or resize applied on release: the frame it had and the one it got, in the page's + /// 0..1 coordinates, while the page raster still has the picture where it was. + settled: Option<(Rect, Rect)>, } /// Cropping a page image: the handles trim instead of resizing, the part that will go is @@ -533,6 +536,25 @@ impl ImageSelection { } } +/// The page raster's picture of the box `from`, stretched into `to` over a blank where it was. +fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, from: Rect, to: Rect) { + let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + clipped.rect_filled(from, CornerRadius::ZERO, Color32::WHITE); + let mut mesh = egui::Mesh::with_texture(tex); + for (a, b) in [ + (from.left_top(), to.left_top()), + (from.right_top(), to.right_top()), + (from.right_bottom(), to.right_bottom()), + (from.left_bottom(), to.left_bottom()), + ] { + let (u, v) = xf.screen_to_norm(a); + mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + clipped.add(egui::Shape::mesh(mesh)); +} + /// Once a selected corner is grabbed, text under it cannot take over the gesture. The same /// applies after the pointer leaves the image/page while dragging. pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool { @@ -697,6 +719,18 @@ pub(crate) fn image_input( ); } }; + // Until the page is rendered again its raster has an applied move or resize's picture + // where it was: it stays blanked there and drawn where it went. + let (out_of_date, raster) = (view.page_out_of_date(page), view.page_raster(page)); + if let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none()) { + match (s.settled, raster) { + (Some((from, to)), Some(tex)) if out_of_date => { + let screen = |r: Rect| Rect::from_two_pos(xf.norm_to_screen(r.min.x, r.min.y), xf.norm_to_screen(r.max.x, r.max.y)); + stretched_picture(painter, xf, tex, screen(from), screen(to)); + } + _ => s.settled = None, + } + } if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); handles(b); @@ -754,21 +788,7 @@ pub(crate) fn image_input( if preview != b && let Some(tex) = view.page_raster(page) { - let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); - clipped.rect_filled(b, CornerRadius::ZERO, Color32::WHITE); - let mut mesh = egui::Mesh::with_texture(tex); - for (from, to) in [ - (b.left_top(), preview.left_top()), - (b.right_top(), preview.right_top()), - (b.right_bottom(), preview.right_bottom()), - (b.left_bottom(), preview.left_bottom()), - ] { - let (u, v) = xf.screen_to_norm(from); - mesh.vertices.push(egui::epaint::Vertex { pos: to, uv: Pos2::new(u, v), color: Color32::WHITE }); - } - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); - clipped.add(egui::Shape::mesh(mesh)); + stretched_picture(painter, xf, tex, b, preview); } painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); @@ -777,6 +797,11 @@ pub(crate) fn image_input( if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { if let Some(s) = view.image_selection.as_mut() { s.drag = None; + let norm = |r: Rect| { + let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max)); + Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1)) + }; + s.settled = (preview != b).then(|| (norm(b), norm(preview))); } if preview != b { view.pending_edit = @@ -832,7 +857,7 @@ pub(crate) fn image_input( _ => egui::CursorIcon::PointingHand, }); if resp.clicked() || resp.secondary_clicked() { - view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None }); + view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None, settled: None }); } if resp.secondary_clicked() { ui.ctx().data_mut(|d| d.insert_temp(menu_key, true)); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index d41041e52e..4a386372ec 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4454,6 +4454,85 @@ fn image_pixels_follow_the_frame_while_a_corner_is_dragged() { } } +/// Red pixels in `r`: the test picture. +fn red(pixels: &image::RgbaImage, r: egui::Rect) -> usize { + let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32); + (y0..y1.min(pixels.height())) + .flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y))) + .filter(|(x, y)| matches!(pixels.get_pixel(*x, *y).0, [r, g, b, _] if r > 150 && g < 90 && b < 90)) + .count() +} + +/// A resized picture is applied when the corner is let go, but the page raster still has it at +/// its old size until the page is rendered again. Until then it stays drawn at its new size and +/// blanked at its old one; the old one used to show again for those frames. +#[test] +fn a_resized_image_keeps_its_new_size_until_the_page_is_redrawn() { + use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; + let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let mut image = Dict::new(); + image.set(b"Type".to_vec(), Object::name("XObject")); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(1)); + image.set(b"Height".to_vec(), Object::Int(1)); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + let image = doc.add(Object::Stream(Stream::flate(image, &[200, 30, 30]))); + let page = pdfcraft_model::pages(&doc).remove(0); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 80 0 0 80 60 40 cm /Im0 Do Q"))); + let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let mut xo = Dict::new(); + xo.set(b"Im0".to_vec(), Object::Ref(image)); + resources.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| { + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + }) + .unwrap(); + let bytes = write_incremental(&doc, &Default::default()).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().views[0].set_zoom(1.2); + h.run_steps(4); + support::wait_until(&mut h, "the page raster", |app| !app.render_pending()); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let at = |x: f32, y: f32| egui::pos2(page.left() + x / 200.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height()); + click_at(&mut h, at(100.0, 80.0)); + assert!(h.state().views[0].image_selection.is_some(), "the picture is selected"); + support::wait_until(&mut h, "the page raster", |app| !app.render_pending()); + // From 60..140 × 40..120 to half its size, keeping its lower left corner. + let (grip, to) = (at(140.0, 120.0), at(100.0, 80.0)); + let kept = egui::Rect::from_two_pos(at(62.0, 42.0), at(98.0, 78.0)); + let gone = egui::Rect::from_two_pos(at(104.0, 84.0), at(138.0, 118.0)); + h.hover_at(grip); + h.run_steps(1); + h.drag_at(grip); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip + (to - grip) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.state_mut().views[0].hold_rasters = true; + h.drop_at(to); + for frame in 0..4 { + h.step(); + let pixels = h.render().unwrap(); + assert!(red(&pixels, kept) > 100, "frame {frame} after the release: the picture is not at its new size"); + assert_eq!(red(&pixels, gone), 0, "frame {frame} after the release: the picture shows at its old size"); + } + h.state_mut().views[0].hold_rasters = false; + support::wait_until(&mut h, "the page drawn with the resized picture", |app| !app.render_pending()); + h.run_steps(2); + let pixels = h.render().unwrap(); + assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size"); +} + /// Exercise actual pointer gestures on each cropped placement, without capturing private pixels. #[test] #[ignore = "requires an explicitly supplied local PDF with cropped artwork"]