diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3d78f952d6..3688816098 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1049,6 +1049,18 @@ impl DocView { self.pages.get(&page).map(|p| p.tex.id()) } + /// 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) + } + /// 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 { @@ -6500,4 +6512,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 d272480d2a..c139a040c5 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -369,6 +369,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 @@ -544,6 +547,28 @@ impl ImageSelection { } } +/// 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)); + 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()), + (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 { @@ -666,14 +691,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); } @@ -708,6 +736,26 @@ 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. + // 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 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 => { + let source = if raster_old { from } else { to }; + stretched_picture(painter, xf, tex, &[screen(from)], screen(source), 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); @@ -765,21 +813,10 @@ 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)); + // 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); @@ -788,6 +825,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(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); } if preview != b { view.pending_edit = @@ -843,7 +885,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 6885a2bb6d..f3bef7bd92 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4549,6 +4549,110 @@ 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); + } + // 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 { + 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"); + } + // 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, 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. #[test] #[ignore = "requires an explicitly supplied local PDF with cropped artwork"]