Merge PR #3952: M7: opening a paragraph leaves the page as it is until it changes

This commit is contained in:
aiongg
2026-10-22 01:45:02 +00:00
2 changed files with 213 additions and 18 deletions
+86 -18
View File
@@ -32,6 +32,9 @@ pub(crate) struct TextBackground {
received: Option<u64>,
draft_key: Option<(String, pdfcraft_engine::BlockStyle)>,
draft_rect: Option<[f64; 4]>,
/// This frame's place for the textless page under the open paragraph's editor. It stays
/// empty, so the page shows its own text, until the paragraph is changed ([`Self::reveal`]).
slot: Option<(egui::Painter, egui::layers::ShapeIdx, egui::Shape)>,
}
impl TextBackground {
@@ -50,6 +53,7 @@ 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)))));
self.slot = None;
if self.key != key {
*self = Self { key, ..Default::default() };
if let Some((_, page, target)) = key {
@@ -101,6 +105,11 @@ impl TextBackground {
}
}
/// The draft's measured extent, when it was measured for paragraph `block` on `page`.
pub fn draft_rect_for(&self, page: usize, block: usize) -> Option<[f64; 4]> {
self.draft_rect.filter(|_| self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Block(block))))
}
pub fn ready(&self, page: usize, block: usize) -> bool {
self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Block(block))) && self.texture.is_some()
}
@@ -110,15 +119,15 @@ impl TextBackground {
}
pub fn paint(
&self,
&mut self,
painter: &egui::Painter,
xf: &PageXform,
page: usize,
editor: Option<&LineEditor>,
draft: Option<&crate::content_ui::TextDraft>,
) {
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)));
let for_editor = editor.is_some_and(|ed| ed.page == page && self.ready(page, ed.block));
let ready = for_editor || draft.is_some_and(|d| d.page == page && d.index.is_some_and(|index| self.ready_added(page, index)));
if !ready {
return;
}
@@ -126,7 +135,27 @@ impl TextBackground {
// Font metrics can underestimate the actual glyph ink. Paint the complete textless
// 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 for_editor {
// Its place under everything drawn on the page later; the editor fills it once the
// paragraph changes (an unchanged paragraph looks as the page draws it).
let mut mesh = egui::Mesh::with_texture(tex.id());
for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] {
mesh.vertices.push(egui::epaint::Vertex { pos: xf.norm_to_screen(u, v), uv: Pos2::new(u, v), color: Color32::WHITE });
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
let idx = painter.add(egui::Shape::Noop);
self.slot = Some((painter, idx, egui::Shape::mesh(mesh)));
} else {
xf.paint_image(&painter, tex.id(), 0.0, 0.0, 1.0, 1.0);
}
}
/// The open paragraph has changed: show the textless page under its editor this frame.
pub fn reveal(&mut self) {
if let Some((painter, idx, shape)) = self.slot.take() {
painter.set(idx, shape);
}
}
}
@@ -221,7 +250,12 @@ impl LineEditor {
}
pub(crate) fn has_changes(&self) -> bool {
self.text != self.original || self.look != self.look0 || self.extras != self.extras0 || self.family_chosen
self.changes_with(&self.text)
}
/// Whether the paragraph with `text` in it differs from what was opened.
fn changes_with(&self, text: &str) -> bool {
text != self.original || self.look != self.look0 || self.extras != self.extras0 || self.family_chosen
}
/// Extra width permitted by the editor. Source containers now remain fixed.
@@ -1310,6 +1344,16 @@ fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32
job
}
/// `galley` drawn in no colour: its rows still place the caret and the selection.
fn unseen(mut galley: Arc<egui::Galley>) -> Arc<egui::Galley> {
for placed in &mut Arc::make_mut(&mut galley).rows {
for vertex in &mut Arc::make_mut(&mut placed.row).visuals.mesh.vertices {
vertex.color = Color32::TRANSPARENT;
}
}
galley
}
/// Align the editable galley's baselines to the source rows instead of egui's interface
/// leading. Glyphs, meshes, caret and selection receive the same transform.
fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> Arc<egui::Galley> {
@@ -1394,7 +1438,13 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
.line_editor
.as_ref()
.is_some_and(|ed| view.text_background.ready(ed.page, ed.block) && !ed.source.as_ref().is_some_and(|(b, _)| b.invisible));
let draft_rect = view.text_background.draft_rect.map(|r| xf.user_rect(info, page, r.map(|v| v as f32)));
// The measured extent of this paragraph's draft: a click straight from another paragraph
// opens this one after the frame's measuring, which was the other one's.
let draft_rect = view
.line_editor
.as_ref()
.and_then(|ed| view.text_background.draft_rect_for(ed.page, ed.block))
.map(|r| xf.user_rect(info, page, r.map(|v| v as f32)));
let ed = view.line_editor.as_mut()?;
if hold {
ed.focus = true;
@@ -1419,6 +1469,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
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 mut done = None;
let mut reveal = false;
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| {
@@ -1453,20 +1504,27 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
// White, unless the text is too pale to read on white (white text from a coloured
// banner, #913): then a dark box, with a caret that shows on it.
let fill = if background_ready { Color32::TRANSPARENT } else { editor_fill(text_color) };
// Until the background worker finishes, cover the entire source box, including
// lines the draft no longer occupies. Shrinking the text must never expose a copy.
if !background_ready {
ui.painter().rect_filled(ed.rect.expand(1.0), CornerRadius::ZERO, fill);
}
egui::Frame::NONE.fill(fill).show(ui, |ui| {
// A textless PDF preview keeps its real page background through the transparent editor.
// The temporary opaque cover uses the shared caret-contrast rule.
ui.visuals_mut().text_cursor.stroke.color =
if background_ready { if luminance(text_color) < 0.5 { Color32::BLACK } else { Color32::WHITE } } else { caret_color_on(fill) };
// An unchanged paragraph is the page's own text: the editor draws only its frame and
// caret over it. Once it changes, the textless page goes under the editor's text or,
// until that background is ready, a cover over the entire source box, including lines
// the draft no longer occupies. Shrinking the text must never expose a copy. Both are
// decided after this frame's typing, in places kept under the editor's text.
let cover = ui.painter().add(egui::Shape::Noop);
let unchanged = !ed.has_changes();
egui::Frame::NONE.show(ui, |ui| {
// A textless PDF preview, or the page itself, keeps its real background through
// the transparent editor. The temporary opaque cover uses the shared caret-contrast rule.
ui.visuals_mut().text_cursor.stroke.color = if background_ready || unchanged {
if luminance(text_color) < 0.5 { Color32::BLACK } else { Color32::WHITE }
} else {
caret_color_on(fill)
};
let rows = 1;
let snapshot = ed.clone();
let mut layouter =
|ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width);
let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| {
let galley = editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width);
if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) }
};
let output = egui::TextEdit::multiline(&mut ed.text)
.id(egui::Id::new("edit-text-line-input"))
.font(font.clone())
@@ -1477,6 +1535,13 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
.desired_width(width)
.desired_rows(rows)
.show(ui);
if ed.has_changes() {
if background_ready {
reveal = true;
} else {
ui.painter().set(cover, egui::Shape::rect_filled(ed.rect.expand(1.0).union(output.response.rect), CornerRadius::ZERO, fill));
}
}
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);
@@ -1501,6 +1566,9 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
});
},
);
if reveal {
view.text_background.reveal();
}
match done {
Some(apply) => {
let ed = view.line_editor.take()?;
+127
View File
@@ -2723,6 +2723,133 @@ fn switching_from_a_narrow_editor_keeps_one_frame_and_a_wide_heading_on_one_line
assert!(!dirty(&h));
}
/// Clicking a paragraph changes nothing on the page but the editor's frame and caret: the page's
/// own text stays until something is typed. The editor used to cover the paragraph (white, or
/// dark for pale text) until its textless background was ready, and drew it again in its own fonts.
#[test]
fn opening_a_paragraph_leaves_the_page_as_it_is() {
let bytes = source_font_fixture_with_text("0 0 0.45 rg 10 180 280 70 re f 1 1 1 rg BT /F2 20 Tf 20 205 Td (White on a banner) 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("banner.pdf", None, bytes).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
for _ in 0..100 {
h.run_steps(1);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let page = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = page.width() / 300.0;
let block = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rect;
let r = egui::Rect::from_min_max(
egui::pos2(page.left() + block[0] as f32 * k, page.top() + (300.0 - block[3] as f32) * k),
egui::pos2(page.left() + block[2] as f32 * k, page.top() + (300.0 - block[1] as f32) * k),
);
let at = egui::pos2(r.left() + 2.0, r.center().y);
h.hover_at(at);
h.run_steps(2);
let before = h.render().unwrap();
// Inside the paragraph, away from the caret (at the click) and the frame around it.
let inside = r.shrink(3.0);
let changed = |img: &image::RgbaImage| {
let mut n = 0;
for y in inside.top() as u32..inside.bottom() as u32 {
for x in inside.left() as u32..inside.right() as u32 {
if (x as f32 - at.x).abs() < 14.0 {
continue;
}
let (a, b) = (img.get_pixel(x, y).0, before.get_pixel(x, y).0);
if a.iter().zip(b.iter()).take(3).any(|(p, q)| (i32::from(*p) - i32::from(*q)).abs() > 40) {
n += 1;
}
}
}
n
};
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
for frame in 0..8 {
h.step();
let n = changed(&h.render().unwrap());
assert!(n < 20, "frame {frame} after the click changed {n} pixels of the paragraph");
}
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("White on a banner"));
}
/// A quick click (press and release in one frame) from an open, unchanged paragraph straight into
/// another one: in no frame does the first paragraph lose its text, and the new editor's frame
/// never spans the first one (it took the first paragraph's measured extent for a frame).
#[test]
fn a_quick_click_into_another_paragraph_keeps_both_in_place() {
let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) 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 page = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = page.width() / 300.0;
let mut blocks = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0)
};
blocks.sort_by(|a, b| b.rect[1].total_cmp(&a.rect[1]));
let screen = |b: &pdfcraft_engine::TextBlock| {
egui::Rect::from_min_max(
egui::pos2(page.left() + b.rect[0] as f32 * k, page.top() + (300.0 - b.rect[3] as f32) * k),
egui::pos2(page.left() + b.rect[2] as f32 * k, page.top() + (300.0 - b.rect[1] as f32) * k),
)
};
let (top, bottom) = (screen(&blocks[0]), screen(&blocks[1]));
click_at(&mut h, top.center());
for _ in 0..100 {
h.run_steps(1);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Top"));
let ink = |pixels: &image::RgbaImage, r: egui::Rect| {
(r.top() as u32..r.bottom() as u32)
.flat_map(|y| (r.left() as u32..r.right() as u32).map(move |x| (x, y)))
.filter(|(x, y)| pixels.get_pixel(*x, *y).0[..3].iter().all(|v| *v < 90))
.count()
};
h.hover_at(bottom.center());
h.run_steps(1);
let before = ink(&h.render().unwrap(), top.shrink(2.0));
assert!(before > 50, "the top paragraph shows");
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: bottom.center(), button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
}
for frame in 0..4 {
h.step();
let now = ink(&h.render().unwrap(), top.shrink(2.0));
assert!(now * 2 > before, "frame {frame}: the top paragraph lost its text ({now} of {before} dark pixels)");
for shape in &h.output().shapes {
if let egui::Shape::Rect(r) = &shape.shape
&& r.stroke.width == 1.5
&& r.rect.contains(bottom.center())
{
assert!(!r.rect.intersects(top), "frame {frame}: the bottom editor's frame {:?} spans the top paragraph {top:?}", r.rect);
}
}
}
assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Bottom"));
}
/// #2022: clicking straight into another text box while one is open for typing must apply the
/// open paragraph first; the typed text used to be dropped with the editor that switched away.
#[test]