From 7a7d39f01f93333d3f42365b66d093bd04969835 Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:15:28 +0800 Subject: [PATCH 1/4] M7: keep a moved or resized page image in place until its page is drawn again A dragged page image is drawn from the page raster, blanked where it was and stretched into its new frame. On release the edit applied and the drag state cleared in the same frame, but the page raster on screen still had the picture at its old size and place until its replacement arrived. For those frames the old picture showed again around the new frame, then jumped. Keep the old and new frames on the selection after a release, and draw the picture the way the drag did while the page is out of date: its raster, or on a tiled page any tile, still waits for a replacement. A failed edit never makes the page stale, so nothing is kept. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 10 ++++ crates/ui-egui/src/edit_text_ui.rs | 57 +++++++++++++++------ crates/ui-egui/tests/editing.rs | 79 ++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 16 deletions(-) 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"] From f632c1b0ca5b172811b4b515083806201fddca5b Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:26:39 +0800 Subject: [PATCH 2/4] M7: a resized page image stays blanked where the raster still has it Two cases kept the old picture on screen after a move or resize: - A page that had no tiles when the edit applied was taken as drawn again as soon as a first tile arrived, while its backdrop still had the picture. Out of date now means its whole-page raster or any tile still waits for a replacement. - A second drag before the page was drawn again took the picture's pixels from its new frame, where the raster doesn't have it, and blanked only that. It now takes them from where the raster still has the picture and blanks that, and keeps it as the source on release. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 10 +++------- crates/ui-egui/src/edit_text_ui.rs | 13 +++++++------ crates/ui-egui/tests/editing.rs | 21 ++++++++++++++++++++- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 20b7a0a2ea..13ff04ec4d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1046,14 +1046,10 @@ 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. + /// Whether `page` is still drawn, somewhere, as it was before its last change: its whole-page + /// raster (a tiled page's backdrop too) or any of its tiles waits 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) - } + self.pages.get(&page).is_some_and(|p| p.tag == STALE_TAG) || self.stale_tiles.iter().any(|k| k.0 == page) } /// The print preview's picture of `page`: the sheet raster when it has arrived, otherwise the diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 5b5a6445f8..adfbc675ae 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -722,12 +722,13 @@ 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)); + 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)); + // Where the raster on screen has the picture: where it was before an applied move that + // hasn't been rendered yet, else its frame. Another drag starts from there too. + let drawn_at = view.image_selection.as_ref().and_then(|s| s.settled).filter(|_| out_of_date).map(|(from, _)| from); 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)); - } + (Some((from, to)), Some(tex)) if out_of_date => stretched_picture(painter, xf, tex, screen(from), screen(to)), _ => s.settled = None, } } @@ -788,7 +789,7 @@ pub(crate) fn image_input( if preview != b && let Some(tex) = view.page_raster(page) { - stretched_picture(painter, xf, tex, b, preview); + stretched_picture(painter, xf, tex, drawn_at.map_or(b, screen), 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); @@ -801,7 +802,7 @@ pub(crate) fn image_input( 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))); + s.settled = (preview != b).then(|| (drawn_at.unwrap_or_else(|| norm(b)), norm(preview))); } if preview != b { view.pending_edit = diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 4a386372ec..67d22f77ae 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4526,11 +4526,30 @@ fn a_resized_image_keeps_its_new_size_until_the_page_is_redrawn() { 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"); } + // Resized again before the page is drawn: the raster still has the first size, so that is + // what stays blanked while the corner moves. + let (grip, to) = (to, at(80.0, 60.0)); + let again = egui::Rect::from_two_pos(at(62.0, 42.0), at(78.0, 58.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); + } + let pixels = h.render().unwrap(); + assert!(red(&pixels, again) > 20, "the second drag draws the picture at its new size"); + assert_eq!(red(&pixels, gone), 0, "the second drag shows the picture at its first size"); + h.drop_at(to); + h.run_steps(2); + let pixels = h.render().unwrap(); + assert_eq!(red(&pixels, gone), 0, "after the second release"); 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"); + assert!(red(&pixels, again) > 20 && 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. From bef4e28a4d713e9c47bde074b8c5180035aaa3e3 Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:52:56 +0800 Subject: [PATCH 3/4] M7: a dragged page image leaves no outline where it was Edit text & images outlines every picture from the page's inventory, which still has the dragged one where it was until the release; that outline trailed behind the picture and caught up after it was let go. Leave the dragged picture's own outline out while it moves. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/edit_text_ui.rs | 7 +++++-- crates/ui-egui/tests/editing.rs | 6 ++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index adfbc675ae..d6ddab2a94 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -677,14 +677,17 @@ pub(crate) fn image_input( // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. // Fully clipped placements (empty visible frames) get no outline. let faint_all = boxes.len() <= MAX_OUTLINES; + // The picture being dragged has its frame at the pointer; its outline where it was would + // trail behind until the release. + let moving = selected.filter(|_| dragging); if faint_all { - for b in boxes.iter().filter(|b| b.is_positive()) { + for (_, b) in boxes.iter().enumerate().filter(|(i, b)| b.is_positive() && moving != Some(*i)) { painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); } } let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p))); if !faint_all { - for i in selected.into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) { + for i in selected.filter(|_| moving.is_none()).into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) { if let Some(b) = boxes.get(i).filter(|b| b.is_positive()) { painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); } diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 67d22f77ae..e1f542bde5 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4518,6 +4518,12 @@ fn a_resized_image_keeps_its_new_size_until_the_page_is_redrawn() { h.hover_at(grip + (to - grip) * (k as f32 / 4.0)); h.run_steps(1); } + // While dragged, no outline stays where the picture was. + let old = egui::Rect::from_two_pos(at(60.0, 40.0), at(140.0, 120.0)); + let trailing = h.output().shapes.iter().any(|shape| { + matches!(&shape.shape, egui::Shape::Rect(r) if r.stroke.width == 0.75 && (r.rect.min - old.min).length() < 2.0 && (r.rect.max - old.max).length() < 2.0) + }); + assert!(!trailing, "the dragged picture's outline trails behind it"); h.state_mut().views[0].hold_rasters = true; h.drop_at(to); for frame in 0..4 { From af333266bf51c9d97f878f4595d408524f871e6e Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:28:34 +0800 Subject: [PATCH 4/4] M7: sample a moved picture from where the fresh raster has it The whole-page raster and the tiles of a page come back separately. Once the whole-page raster is fresh it has the picture where it went, but the settled preview still took its pixels from where it was, now empty, and drew them over it until the last stale tile arrived; a drag begun in that interval did the same. Keep blanking the old frame while anything is still stale, but take the pixels from the old frame only while the whole-page raster itself is stale (page_raster_out_of_date), else from where the picture went; a drag in that interval blanks the first edit's frame and samples its own. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 122 +++++++++++++++++++++++++++++ crates/ui-egui/src/edit_text_ui.rs | 33 +++++--- 2 files changed, 145 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 13ff04ec4d..5dfb0b3fdf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1048,6 +1048,12 @@ impl DocView { /// Whether `page` is still drawn, somewhere, as it was before its last change: its whole-page /// raster (a tiled page's backdrop too) or any of its tiles waits for a replacement. + /// Whether `page`'s whole-page raster (a tiled page's backdrop too) is still the one from + /// before its last change, whatever its tiles are. + pub(crate) fn page_raster_out_of_date(&self, page: usize) -> bool { + self.pages.get(&page).is_some_and(|p| p.tag == STALE_TAG) + } + pub(crate) fn page_out_of_date(&self, page: usize) -> bool { self.pages.get(&page).is_some_and(|p| p.tag == STALE_TAG) || self.stale_tiles.iter().any(|k| k.0 == page) } @@ -6484,4 +6490,120 @@ trailer << /Root 1 0 R >> assert_eq!(v.find.as_ref().unwrap().matches, vec![(1, 5..9)]); assert!(v.goto.is_none()); } + + /// A page whose picture moved can get its whole-page raster back before its last stale tile. + /// From then on the raster has the picture where it went: it is drawn from there, not from + /// where it was (which the raster now shows empty), and another drag starts from there too. + #[test] + fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale() { + let content = b"q 60 0 0 60 40 260 cm /Im0 Do Q"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content.as_slice(), b"\nendstream"].concat(), + [ + b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceRGB /Length 3 >>\nstream\n".as_slice(), + &[200, 30, 30], + b"\nendstream", + ] + .concat(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(o); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut h = egui_kittest::Harness::builder().with_size(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.clone()).unwrap(); + app.set_option("zoom", "100").unwrap(); + app.quick_tool = QuickTool::EditText; + app + }); + let settle = |h: &mut egui_kittest::Harness<'static, PdfCraftApp>| { + for _ in 0..400 { + h.step(); + if !h.state().render_pending() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + }; + h.run_steps(4); + settle(&mut h); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let at = |x: f32, y: f32| egui::pos2(page.left() + x / 300.0 * page.width(), page.top() + (400.0 - y) / 400.0 * page.height()); + let red = |pixels: &image::RgbaImage, r: Rect| { + let (w, hh) = (pixels.width(), pixels.height()); + (r.top().max(0.0) as u32..(r.bottom() as u32).min(hh)) + .flat_map(|y| (r.left().max(0.0) as u32..(r.right() as u32).min(w)).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() + }; + let (old, new) = (Rect::from_two_pos(at(42.0, 262.0), at(98.0, 318.0)), Rect::from_two_pos(at(182.0, 82.0), at(238.0, 138.0))); + // Select the picture and drag it, without overlap, from (40, 260) to (180, 80). + let drag = |h: &mut egui_kittest::Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2, release: bool| { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.run_steps(1); + } + if release { + h.drop_at(to); + h.step(); + } + }; + let (grab, put) = (at(70.0, 290.0), at(210.0, 110.0)); + h.hover_at(grab); + h.run_steps(1); + h.drag_at(grab); + h.run_steps(1); + h.drop_at(grab); + h.run_steps(3); + assert!(h.state().views[0].image_selection.is_some(), "the picture is selected"); + h.state_mut().views[0].hold_rasters = true; + drag(&mut h, grab, put, true); + // One tile stays stale, as a tile result arriving after the whole-page raster would; then + // the whole-page raster is let through. + let tile = h.ctx.load_texture("stale-tile", egui::ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), TextureOptions::LINEAR); + h.state_mut().views[0].tiles.insert((0, 1, 0, 0), tile); + h.state_mut().views[0].stale_tiles.insert((0, 1, 0, 0)); + h.state_mut().views[0].hold_rasters = false; + for _ in 0..400 { + h.step(); + if !h.state().views[0].page_raster_out_of_date(0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!h.state().views[0].page_raster_out_of_date(0), "the whole-page raster is fresh"); + assert!(h.state().views[0].page_out_of_date(0), "a tile is still stale"); + h.run_steps(2); + let pixels = h.render().unwrap(); + assert!(red(&pixels, new) > 100, "the picture shows where it went"); + assert_eq!(red(&pixels, old), 0, "and not where it was"); + // Another drag in that interval takes its pixels from where the raster has them. + let further = at(210.0, 200.0); + drag(&mut h, put, further, false); + let pixels = h.render().unwrap(); + let moving = Rect::from_two_pos(at(182.0, 172.0), at(238.0, 228.0)); + assert!(red(&pixels, moving) > 100, "the dragged picture is drawn from the fresh raster"); + assert_eq!(red(&pixels, old), 0, "nothing shows where it first was"); + h.state_mut().views[0].stale_tiles.clear(); + h.state_mut().views[0].tiles.clear(); + } } diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d6ddab2a94..a0dacebb58 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -536,10 +536,13 @@ 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) { +/// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where +/// the screen may still show the picture). +fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) { let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); - clipped.rect_filled(from, CornerRadius::ZERO, Color32::WHITE); + for r in blank { + clipped.rect_filled(*r, CornerRadius::ZERO, Color32::WHITE); + } let mut mesh = egui::Mesh::with_texture(tex); for (a, b) in [ (from.left_top(), to.left_top()), @@ -724,14 +727,21 @@ 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)); + // The whole-page raster and its tiles are replaced separately: the old frame stays blanked + // while anything still shows the picture there, but its pixels come from the old frame + // only while the whole-page raster itself is the old one. Once that is fresh it has the + // picture where it went, and that is where they come from. + let (out_of_date, raster_old, raster) = (view.page_out_of_date(page), view.page_raster_out_of_date(page), view.page_raster(page)); 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)); - // Where the raster on screen has the picture: where it was before an applied move that - // hasn't been rendered yet, else its frame. Another drag starts from there too. - let drawn_at = view.image_selection.as_ref().and_then(|s| s.settled).filter(|_| out_of_date).map(|(from, _)| from); + // Where the screen may still show the picture from before an applied move, while the page + // isn't drawn again yet. Another drag keeps it blanked too. + let left_at = view.image_selection.as_ref().and_then(|s| s.settled).filter(|_| out_of_date).map(|(from, _)| from); 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 => stretched_picture(painter, xf, tex, screen(from), screen(to)), + (Some((from, to)), Some(tex)) if out_of_date => { + let source = if raster_old { from } else { to }; + stretched_picture(painter, xf, tex, &[screen(from)], screen(source), screen(to)); + } _ => s.settled = None, } } @@ -792,7 +802,10 @@ pub(crate) fn image_input( if preview != b && let Some(tex) = view.page_raster(page) { - stretched_picture(painter, xf, tex, drawn_at.map_or(b, screen), preview); + // Its pixels: where the whole-page raster has it. + let source = left_at.filter(|_| raster_old).map_or(b, screen); + let blank: Vec = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect(); + stretched_picture(painter, xf, tex, &blank, source, 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); @@ -805,7 +818,7 @@ pub(crate) fn image_input( 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(|| (drawn_at.unwrap_or_else(|| norm(b)), norm(preview))); + s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); } if preview != b { view.pending_edit =