diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a284..2f73aa84c8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1898,6 +1898,7 @@ impl DocView { self.pages.insert(page, PageTex { tag: r.request.tag, tex }); self.signature_drag.page_received(page); self.move_background.page_received(page); + self.text_background.page_received(page); self.comments.page_rendered(page); self.waiting_since.remove(&page); if !self.stale_tiles.iter().any(|k| k.0 == page) { diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 66ad98ffe6..bb2a6bbc44 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -32,6 +32,22 @@ pub(crate) struct TextBackground { received: Option, draft_key: Option<(String, pdfcraft_engine::BlockStyle)>, draft_rect: Option<[f64; 4]>, + /// How the open paragraph looked in its editor last frame. + look: Option, + /// An applied paragraph's last look. The page raster still has the old text until the page + /// is rendered again, so the textless page and this look stay up until then. + settling: Option, +} + +/// A paragraph editor's text as last drawn: its laid-out rows, where they were on the page (in +/// the page's 0..1 coordinates) and at what page width on screen. +struct EditorLook { + page: usize, + block: usize, + galley: Arc, + at: (f32, f32), + color: Color32, + page_width: f32, } impl TextBackground { @@ -39,6 +55,34 @@ impl TextBackground { self.renderer.is_some() && self.received != self.tag } + /// The open paragraph's editor drew `galley` at `pos` this frame. + pub fn remember(&mut self, xf: &PageXform, page: usize, block: usize, galley: Arc, pos: Pos2, color: Color32) { + self.look = Some(EditorLook { page, block, galley, at: xf.screen_to_norm(pos), color, page_width: xf.rect.width() }); + } + + /// An edit was applied. When it is the open paragraph's, its last look stays up over the + /// textless page until the page is rendered again; any other edit ends both. + pub fn committed(&mut self, edit: &Edit) { + let look = self.look.take().filter(|look| { + matches!(edit, Edit::EditTextBlock { page, block, .. } if (look.page, look.block) == (*page, *block)) + && self.key.is_some_and(|(_, page, target)| (page, target) == (look.page, TextTarget::Block(look.block))) + && self.texture.is_some() + }); + match look { + Some(look) => { + self.renderer = None; + self.settling = Some(look); + } + None => *self = Self::default(), + } + } + + pub fn page_received(&mut self, page: usize) { + if self.settling.as_ref().is_some_and(|look| look.page == page) { + *self = Self::default(); + } + } + pub fn prepare( &mut self, ctx: &egui::Context, @@ -50,6 +94,9 @@ impl TextBackground { let key = editor .map(|ed| (doc.edit_generation(), ed.page, TextTarget::Block(ed.block))) .or_else(|| draft.and_then(|d| d.index.map(|index| (doc.edit_generation(), d.page, TextTarget::Added(index))))); + if key.is_none() && self.settling.is_some() { + return; + } if self.key != key { *self = Self { key, ..Default::default() }; if let Some((_, page, target)) = key { @@ -117,8 +164,12 @@ impl TextBackground { editor: Option<&LineEditor>, draft: Option<&crate::content_ui::TextDraft>, ) { + // A zoom since the paragraph was applied would misplace its rows: the page raster + // (with the old text) shows until the new one arrives instead. + let settled = self.settling.as_ref().filter(|look| look.page == page && (look.page_width - xf.rect.width()).abs() < 0.5); let ready = editor.is_some_and(|ed| ed.page == page && self.ready(page, ed.block)) - || draft.is_some_and(|d| d.page == page && d.index.is_some_and(|index| self.ready_added(page, index))); + || draft.is_some_and(|d| d.page == page && d.index.is_some_and(|index| self.ready_added(page, index))) + || settled.is_some(); if !ready { return; } @@ -127,6 +178,9 @@ impl TextBackground { // page so overhanging glyphs cannot remain outside the estimated source rectangle. let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); xf.paint_image(&painter, tex.id(), 0.0, 0.0, 1.0, 1.0); + if let Some(look) = settled { + painter.galley(xf.norm_to_screen(look.at.0, look.at.1), Arc::clone(&look.galley), look.color); + } } } @@ -1336,7 +1390,9 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - ed.source.as_ref().and_then(|(block, lines)| block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size as f32).reduce(f32::min)); let font = editor_font(&ed.look, smallest.map_or(ed.size, |s| (s * scale * 0.5).clamp(1.0, 300.0).min(ed.size))); let text_color = color32(ed.look.color); + let block = ed.block; let mut done = None; + let mut look = None; egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show( ctx, |ui| { @@ -1395,6 +1451,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - .desired_width(width) .desired_rows(rows) .show(ui); + look = Some((Arc::clone(&output.galley), output.galley_pos)); if let Some(cursor) = output.cursor_range { let selected = cursor.as_sorted_char_range(); let byte = |index| ed.text.char_indices().nth(index).map_or(ed.text.len(), |(at, _)| at); @@ -1419,6 +1476,9 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - }); }, ); + if let Some((galley, pos)) = look { + view.text_background.remember(&xf, page, block, galley, pos, text_color); + } match done { Some(apply) => { let ed = view.line_editor.take()?; diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 91480ac7b5..4bb6f6c94e 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -68,6 +68,7 @@ impl PdfCraftApp { } view.signature_drag.committed(&edit, doc.edit_generation()); view.move_background.committed(&edit, doc.edit_generation()); + view.text_background.committed(&edit); 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/editing.rs b/crates/ui-egui/tests/editing.rs index d41041e52e..36e0b79db0 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2735,6 +2735,67 @@ fn switching_straight_to_another_box_applies_the_open_paragraph() { assert_eq!(ed.text, "Bottom"); } +/// Dark pixels in `r`: glyph ink on a white page. +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() +} + +/// Applying a paragraph changes the page, whose raster still has the old text until it is +/// rendered again. Until then the paragraph keeps the look it had in the editor; the old text +/// used to come back under the new outline for those frames and then jump to the new one. +#[test] +fn an_applied_paragraph_keeps_its_edited_look_until_the_page_is_redrawn() { + let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Topmost) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET"); + 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("boxes.pdf", None, bytes).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 300.0; + let top = { + let s = h.state(); + let blocks = s.session.get(s.views[0].id).unwrap().text_blocks(0); + let b = blocks.iter().find(|b| b.text == "Topmost").expect("the top box").rect; + egui::Rect::from_min_max( + egui::pos2(r.left() + b[0] as f32 * k, r.top() + (300.0 - b[3] as f32) * k), + egui::pos2(r.left() + b[2] as f32 * k, r.top() + (300.0 - b[1] as f32) * k), + ) + }; + // "T" stays; the rest of the word goes. + let head = egui::Rect::from_min_max(top.min, egui::pos2(top.left() + top.width() * 0.12, top.bottom())); + let tail = egui::Rect::from_min_max(egui::pos2(top.left() + top.width() * 0.3, top.top()), top.max).shrink(2.0); + click_at(&mut h, top.center()); + support::wait_until(&mut h, "the editor's textless page", |app| !app.render_pending()); + h.state_mut().views[0].line_editor.as_mut().expect("the top box is open").text = "T".into(); + h.run_steps(2); + h.state_mut().views[0].hold_rasters = true; + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + for frame in 0..4 { + h.step(); + assert!(h.state().views[0].line_editor.is_none(), "applied"); + let pixels = h.render().unwrap(); + assert!(ink(&pixels, head) > 20, "frame {frame} after applying shows no text"); + assert_eq!(ink(&pixels, tail), 0, "frame {frame} after applying shows the old text again"); + } + h.state_mut().views[0].hold_rasters = false; + support::wait_until(&mut h, "the page drawn with the edit", |app| !app.render_pending()); + h.run_steps(2); + let pixels = h.render().unwrap(); + assert!(ink(&pixels, head) > 20, "the page draws the applied text"); + assert_eq!(ink(&pixels, tail), 0, "and not the old one"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect::>(), ["T", "Bottom"]); +} + #[test] fn editing_existing_text_in_place() { let mut h = harness(1, |_| {});