diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index d72b49d1..d1801696 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -63,4 +63,16 @@ impl Document { Ok(TextEditPreview { page, bytes, config: self.config.clone() }) })? } + + /// The page beneath an added item while it is dragged: `index` counts the page's added + /// items, as `Edit::DeleteContent` does. Read-only, like [`Self::text_edit_preview`]. + pub fn added_content_preview(&self, page: usize, index: usize) -> Result { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + pdfcraft_edit::delete_content(&mut cos, page, index).map_err(|e| e.to_string())?; + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(TextEditPreview { page, bytes, config: self.config.clone() }) + })? + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1f9105e2..82c07235 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -394,6 +394,7 @@ pub struct DocView { pub(crate) edit_lines: HashMap)>, pub line_editor: Option, pub(crate) text_background: crate::edit_text_ui::TextBackground, + pub(crate) move_background: crate::content_ui::MoveBackground, /// Edit text & images: the images per page (by document generation), and the selected one. pub(crate) edit_images: HashMap)>, pub(crate) objects: crate::object_ui::ObjectSelection, @@ -635,6 +636,7 @@ impl DocView { edit_lines: HashMap::new(), line_editor: None, text_background: Default::default(), + move_background: Default::default(), edit_images: HashMap::new(), objects: Default::default(), image_selection: None, @@ -780,7 +782,7 @@ impl DocView { } pub fn render_pending(&self) -> bool { - !self.last_queue.is_empty() || self.text_background.pending() + !self.last_queue.is_empty() || self.text_background.pending() || self.move_background.pending() } /// Re-render every page and text layer (the document's appearance changed, e.g. a layer @@ -1343,6 +1345,7 @@ impl DocView { // Signature previews own a separate renderer and image/background textures. self.signature_drag = Default::default(); self.text_background = Default::default(); + self.move_background = Default::default(); if !self.last_queue.is_empty() { pool.set_queue(Vec::new()); self.last_queue.clear(); @@ -1614,6 +1617,7 @@ impl DocView { let tex = ctx.load_texture(format!("page-{:?}-{page}", self.id), img, TextureOptions::LINEAR); self.pages.insert(page, PageTex { tag: r.request.tag, tex }); self.signature_drag.page_received(page); + self.move_background.page_received(page); self.waiting_since.remove(&page); if !self.stale_tiles.iter().any(|k| k.0 == page) { self.forms.unbaked.retain(|(_, p)| *p != page); @@ -2347,6 +2351,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { view.content.selected = None; } + view.move_background.prepare(ui.ctx(), doc, &view.content, scale); // A destination's position (bookmarks, links) refines `goto`: its point at the window's // top-left, or the centre of a /FitR rectangle at the window's centre. @@ -2546,6 +2551,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } view.text_background.paint(painter, &xf, i, view.line_editor.as_ref()); + if editing_content { + let page_tex = view.pages.get(&i).map(|p| p.tex.id()); + view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex); + } painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); // Comments: tools, selection, moving and resizing come before text selection. diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5553c850..39355e69 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -105,6 +105,128 @@ impl ContentView { } } +/// While an added item is moved, the page without it, rendered once per gesture: the item's own +/// pixels follow the pointer over it instead of staying put until release (#1488). +#[derive(Default)] +pub(crate) struct MoveBackground { + key: Option<(u64, usize, usize)>, + renderer: Option, + texture: Option, + tag: Option, + /// The item's display-space box when the gesture started. + from: Option<[f64; 4]>, + /// The applied move's box. The page raster on screen still has the item where it was until + /// its replacement arrives, so the preview stays up until then. + settling: Option<[f64; 4]>, +} + +impl MoveBackground { + pub fn pending(&self) -> bool { + self.renderer.is_some() && self.texture.is_none() + } + + pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, cv: &ContentView, scale: f32) { + let key = match (cv.grab, cv.selected) { + (Some((gp, Grab::Move, _)), Some((sp, si))) if gp == sp => Some((doc.edit_generation(), sp, si)), + _ => None, + }; + if !(key.is_none() && self.settling.is_some()) && self.key != key { + *self = Self { key, ..Default::default() }; + if let Some((_, page, index)) = key { + self.renderer = doc.added_content_preview(page, index).ok().map(|p| p.renderer()); + self.from = on_page(&doc.added, page).into_iter().find(|(i, _)| *i == index).map(|(_, a)| a.content.rect()); + } + } + if self.settling.is_some() { + return; + } + let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return }; + let Some(p) = doc.info.pages.get(page) else { return }; + let max_side = ctx.input(|i| i.max_texture_side) as f32; + let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0)); + let tag = (scale * 1000.0) as u64; + if self.tag != Some(tag) { + renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + self.tag = Some(tag); + } + if let Some(out) = renderer.try_recv() { + if out.error.is_some() { + // Without a background the item stays put while dragged, as before. + self.renderer = None; + return; + } + if out.request.tag == tag { + self.texture = Some(ctx.load_texture( + "added-content-move-background", + crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba), + egui::TextureOptions::LINEAR, + )); + } + } + if self.pending() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// The move was applied: keep the preview, now at the new generation, until the page is + /// rendered again. Any other edit ends it. + pub fn committed(&mut self, edit: &Edit, generation: u64) { + match edit { + Edit::UpdateContent { page, index, content } if self.texture.is_some() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => { + self.key = Some((generation, *page, *index)); + self.settling = Some(content.rect()); + } + _ => *self = Self::default(), + } + } + + pub fn page_received(&mut self, page: usize) { + if self.settling.is_some() && self.key.is_some_and(|(_, p, _)| p == page) { + *self = Self::default(); + } + } + + /// The page without the moving item, then the item's pixels taken from `page_tex` (the page + /// as rendered, item included where it was) at the dragged or applied position. + #[allow(clippy::too_many_arguments)] + pub fn paint( + &self, + ui: &egui::Ui, + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + cv: &ContentView, + page_tex: Option, + ) { + let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return }; + if kp != page { + return; + } + let r = screen_rect(xf, info, page, from); + let d = if let Some((_, _, start)) = cv.grab { + let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return }; + p - start + } else if let Some(to) = self.settling { + // A moved text box keeps its top edge (its height follows its lines). + screen_rect(xf, info, page, to).left_top() - r.left_top() + } else { + return; + }; + let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); + let r = r.expand(1.0); + let mut mesh = egui::Mesh::with_texture(page_tex); + for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] { + let (u, v) = xf.screen_to_norm(c); + mesh.vertices.push(egui::epaint::Vertex { pos: c + d, uv: Pos2::new(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); + } +} + /// Text style for new text (the panel's Format controls set it). pub fn default_style() -> AddedText { AddedText { size: 12.0, ..AddedText::default() } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6410898c..b3b02a38 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -43,6 +43,7 @@ impl PdfCraftApp { let info = &doc.info; let view = &mut self.views[i]; view.signature_drag.committed(&edit, doc.edit_generation()); + view.move_background.committed(&edit, doc.edit_generation()); if let Some(page) = comment_page(&edit).or_else(|| content_page(&edit)) { // Comment and content edits change one page: keep every other raster. view.page_changed(page); diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index 2435d776..72214489 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -1,16 +1,20 @@ //! Edit a PDF ▸ Add content in the real shell (egui_kittest): type text onto a page, move it, //! restyle it from the panel, add an image, delete with the keyboard. -use egui::Pos2; +use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + harness_bytes(include_bytes!("data/form.pdf").to_vec()) +} + +fn harness_bytes(bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.open_bytes("form.pdf", None, bytes.clone()).unwrap(); app.set_option("zoom", "150").unwrap(); app }); @@ -231,3 +235,128 @@ fn typing_thai_text_keeps_marks_and_commits_to_page() { click(&mut h, p); assert_eq!(texts(&h), ["น้ำ กิ่ ผู้ใช้ Hello"]); } + +/// The screen rectangle of an added item's display-space box (y up, from the page's bottom). +fn item_on_screen(h: &Harness<'static, PdfCraftApp>, r: [f64; 4]) -> egui::Rect { + let s = h.state(); + let ph = s.session.get(s.views[0].id).unwrap().info.pages[0].height; + let page = s.views[0].page_screen_rect(0).expect("on screen"); + let k = page.width() / 300.0; + let p = |x: f64, y: f64| page.min + egui::vec2(x as f32 * k, (ph - y as f32) * k); + egui::Rect::from_two_pos(p(r[0], r[1]), p(r[2], r[3])) +} + +/// Dark pixels in `r`: glyph ink (the selection outline is blue, the page white). +fn ink(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)| pixels.get_pixel(*x, *y).0[..3].iter().all(|v| *v < 90)) + .count() +} + +/// Only the left part of the glyphs: kittest paints its cursor down and right of the pointer. +fn probe(r: egui::Rect) -> egui::Rect { + egui::Rect::from_min_size(r.min, egui::vec2(r.width() * 0.6, r.height())) +} + +/// Ink in the probes of the text's first and dragged places. +fn ink_at(h: &mut Harness<'static, PdfCraftApp>, from: egui::Rect, to: egui::Rect) -> (usize, usize) { + let pixels = h.render().unwrap(); + (ink(&pixels, probe(from)), ink(&pixels, probe(to))) +} + +/// Types text, finishes it, and drags it up without releasing, until the drag preview shows. +/// Returns the text's box, its screen rectangles before and after, and the pointer. +fn drag_added_text(h: &mut Harness<'static, PdfCraftApp>) -> ([f64; 4], egui::Rect, egui::Rect, Pos2) { + assert!(h.state_mut().execute("edit.text")); + h.run_steps(2); + let p = at(h, 40.0, 300.0); + click(h, p); + h.event(egui::Event::Text("MMMMMMMM".into())); + h.run_steps(2); + h.get_by_label("Done adding text").click(); + h.run_steps(3); + let rect = added(h)[0].content.rect(); + let from = item_on_screen(h, rect); + let mut shown = 0; + for _ in 0..100 { + h.run_steps(2); + shown = ink(&h.render().unwrap(), probe(from)); + if shown > 20 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(shown > 20, "the added text is on the page ({shown} dark pixels)"); + let grab = pos2(from.right() - 12.0, from.center().y); + let shift = egui::vec2(0.0, -150.0); + h.hover_at(grab); + h.run_steps(1); + h.drag_at(grab); + h.run_steps(1); + h.hover_at(grab + shift); + let to = from.translate(shift); + for _ in 0..100 { + h.run_steps(2); + let (old, new) = ink_at(h, from, to); + if old == 0 && new > 20 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + (rect, from, to, grab + shift) +} + +/// #1488: while a finished text box is dragged, its text follows the box instead of staying put +/// until the button is released. +#[test] +fn dragged_text_follows_the_pointer_before_release() { + let mut h = harness(); + let (rect, from, to, end) = drag_added_text(&mut h); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + h.render().unwrap().save(format!("{dir}/added-text-drag.png")).unwrap(); + } + let (old, new) = ink_at(&mut h, from, to); + assert!(new > 20, "the text is drawn where the box was dragged ({new} dark pixels)"); + assert_eq!(old, 0, "and no longer where it was"); + assert_eq!(added(&h)[0].content.rect(), rect, "nothing is applied before release"); + h.drop_at(end); + h.run_steps(3); + assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it"); +} + +/// After release the text stays in its new place while the page is re-rendered with the move, +/// instead of jumping back to where it was for those frames. +#[test] +fn released_text_does_not_jump_back_before_the_page_is_redrawn() { + // A page slow enough to render that the frames before its new raster can be looked at: many + // pale squares, too light to count as ink. + let mut content = String::from("0.9 g\n"); + for i in 0..60_000 { + content.push_str(&format!("{} {} 1 1 re f\n", i % 290, (i / 290) % 390)); + } + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let mut h = harness_bytes(pdf.into_bytes()); + let (rect, from, to, end) = drag_added_text(&mut h); + h.drop_at(end); + // Frame by frame until the page has been re-rendered. The one frame that swaps in the new + // raster reads back blank here (kittest renders it after its old texture is freed). + let mut shown = Vec::new(); + while h.state().render_pending() || shown.is_empty() { + h.run_steps(1); + assert!(shown.len() < 500, "the page is re-rendered"); + let (old, new) = ink_at(&mut h, from, to); + assert_eq!(old, 0, "frame {} after release: the text is back where it was", shown.len() + 1); + shown.push(new); + } + assert!(shown.len() > 2, "frames before the new raster: {shown:?}"); + assert!(shown.iter().filter(|n| **n <= 20).count() <= 1, "the text stays where it was released: {shown:?}"); + assert_ne!(added(&h)[0].content.rect(), rect, "the move is applied"); + h.run_steps(2); + let (old, new) = ink_at(&mut h, from, to); + assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)"); +}