From 44fbacfffd008c9ed8e83be784b09a514a764fba Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:19:45 +0200 Subject: [PATCH] Edit text & images: a dragged image's pixels follow its frame instead of waiting for the release (part of #1671) (#1677) --- crates/ui-egui/src/canvas.rs | 5 +++ crates/ui-egui/src/edit_text_ui.rs | 22 ++++++++++ crates/ui-egui/tests/editing.rs | 68 ++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 959337e2f..2e23c2a97 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -888,6 +888,11 @@ impl DocView { self.thumbs.get(&page).map(|t| t.tex.id()) } + /// The whole-page raster on screen for `page` (the low-res backdrop of a tiled page), if any. + pub(crate) fn page_raster(&self, page: usize) -> Option { + self.pages.get(&page).map(|p| p.tex.id()) + } + /// 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 65c50528e..9a0870880 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -527,6 +527,28 @@ pub(crate) fn image_input( let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); let visible = preview.intersect(xf.rect); if visible.is_positive() { + // The pixels follow the frame: the page raster's picture of the image, stretched + // into the preview over a blank where it was. Re-rendering every frame of a drag + // would lag; the edit renders it properly on release. + 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)); + } 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); handles(preview); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 2708b6c3d..7c2f96f34 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -3929,6 +3929,74 @@ fn cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop() { assert_eq!(reopened.get(re).unwrap().page_images(0), h.state().session.get(id).unwrap().page_images(0)); } +/// While a corner is dragged, the image's pixels are drawn in the blue frame, not left at their +/// old size until release (#1671). +#[test] +fn image_pixels_follow_the_frame_while_a_corner_is_dragged() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("clipped.pdf", None, clipped_artwork_with_labels_drawn(true)).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); + 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()); + let p = at(95.0, 195.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + assert!(h.state().views[0].image_selection.is_some()); + let frame = |h: &Harness<'static, PdfCraftApp>| { + h.output().shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Rect(r) if r.stroke.color == egui::Color32::from_rgb(0x14, 0x73, 0xE6) && r.rect.width() > 20.0 => Some(r.rect), + _ => None, + }) + }; + // Textured meshes smaller than the page: the page raster itself always covers the page. + let pictures = |h: &Harness<'static, PdfCraftApp>| -> Vec { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => Some(m.calc_bounds()), + _ => None, + }) + .filter(|b| b.width() < page.width() - 1.0) + .collect() + }; + let start = frame(&h).expect("the selected image has a frame"); + let (grip, fixed) = (start.right_top(), start.left_bottom()); + for factor in [0.6, 1.3] { + let to = fixed + (grip - fixed) * factor; + 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 live = frame(&h).expect("the drag shows its frame"); + assert!((live.width() - start.width() * factor).abs() < 1.0, "the frame follows the grip: {live:?} from {start:?}"); + let drawn = pictures(&h); + assert!( + drawn.iter().any(|b| (b.min - live.min).length() < 0.5 && (b.max - live.max).length() < 0.5), + "the picture is drawn in the frame {live:?}, not only at {drawn:?}" + ); + h.drop_at(to); + h.run_steps(4); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + } +} + /// Exercise actual pointer gestures on each cropped placement, without capturing private pixels. #[test] #[ignore = "requires an explicitly supplied local PDF with cropped artwork"]