mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 10:20:32 -06:00
M7: opening a paragraph leaves the page as it is until it changes
Clicking a paragraph covered it with an opaque box until its textless background was rendered, then redrew it in the editor's own fonts; a quick click from one open paragraph into another also painted the first one's textless page with no editor over it, and framed the second one with the first one's measured extent. The textless page now goes into a place kept under the page's later shapes, and the editor fills it only once the paragraph has changed; until then the page raster shows its own text and the editor draws its text in no colour, for the caret and the selection only. The cover is kept for a changed paragraph whose background isn't ready, decided after the frame's typing. The frame uses a measured extent only when it was measured for the open paragraph. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6dec57bf80
commit
458c20d3f6
@@ -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.
|
||||
@@ -1241,6 +1275,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> {
|
||||
@@ -1325,7 +1369,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;
|
||||
@@ -1350,6 +1400,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| {
|
||||
@@ -1384,20 +1435,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())
|
||||
@@ -1408,6 +1466,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);
|
||||
@@ -1432,6 +1497,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()?;
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user