diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d272480d2a..3f22df46e1 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -32,6 +32,9 @@ pub(crate) struct TextBackground { received: Option, 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) -> Arc { + 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 { @@ -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()?; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 6885a2bb6d..65a71abe4d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -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]