From f7fc209498891ae26a5e8fdff35055fc22c06427 Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:10:44 +0800 Subject: [PATCH] M7: keep an applied paragraph's look until its page is drawn again Applying a paragraph rewrites its page, but the page raster on screen still has the old text until the page is rendered again. The textless background and the editor went away on the frame the edit applied, so for those frames the old text came back under the paragraph's new outline, and then jumped to the new one. The editor now remembers how it drew its text each frame. When the edit that closes it is that paragraph's, the textless page and that last look stay on screen until the page's new raster arrives, as MoveBackground does for moved items. Any other edit, a zoom, or the raster ends it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/edit_text_ui.rs | 62 +++++++++++++++++++++++++++++- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/tests/editing.rs | 61 +++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) 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, |_| {});