mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 18:31:10 -06:00
Merge PR #3952: M7: opening a paragraph leaves the page as it is until it changes
This commit is contained in:
@@ -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()?;
|
||||
|
||||
@@ -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