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 <noreply@anthropic.com>
This commit is contained in:
komo1iddinandClaude Opus 5.5 committed 2026-10-21 23:10:44 +08:00
1 parent 4164951453
commit f7fc209498
4 files changed
+124 -1

No files matched your search

+1
View File
@@ -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) {
+61 -1
View File
@@ -32,6 +32,22 @@ pub(crate) struct TextBackground {
received: Option<u64>,
draft_key: Option<(String, pdfcraft_engine::BlockStyle)>,
draft_rect: Option<[f64; 4]>,
/// How the open paragraph looked in its editor last frame.
look: Option<EditorLook>,
/// 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<EditorLook>,
}
/// 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<egui::Galley>,
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<egui::Galley>, 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()?;
+1
View File
@@ -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);
+61
View File
@@ -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::<Vec<_>>(), ["T", "Bottom"]);
}
#[test]
fn editing_existing_text_in_place() {
let mut h = harness(1, |_| {});