diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 82291713c..536a35e54 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -146,6 +146,41 @@ impl PageText { .map(|(i, _)| i) } + /// The word around glyph `i` as its first and last glyph (double-click): it stops at word + /// gaps, blank glyphs and line ends. + pub fn word_at(&self, i: usize) -> Option<(usize, usize)> { + self.glyphs.get(i)?; + let blank = |k: usize| self.glyphs.get(k).is_none_or(|g| g.text.trim().is_empty()); + // Glyph `k` (≥ 1) follows glyph `k - 1` with no word gap or line break between them. + let joined = |k: usize| !self.space_before.get(k).copied().unwrap_or(true) && self.line_of.get(k - 1) == self.line_of.get(k); + let mut first = i; + while first > 0 && joined(first) && !blank(first - 1) { + first -= 1; + } + let mut last = i; + while joined(last + 1) && !blank(last + 1) { + last += 1; + } + Some((first, last)) + } + + /// The line around glyph `i` as its first and last glyph (triple-click). A line's glyphs are + /// consecutive. + pub fn line_at(&self, i: usize) -> Option<(usize, usize)> { + self.glyphs.get(i)?; + let line = self.line_of.get(i)?; + let same = |k: usize| self.line_of.get(k) == Some(line); + let mut first = i; + while first > 0 && same(first - 1) { + first -= 1; + } + let mut last = i; + while same(last + 1) { + last += 1; + } + Some((first, last)) + } + /// Merge the boxes of `range` into one rectangle per line (for highlighting). pub fn line_rects(&self, range: std::ops::Range) -> Vec<[f32; 4]> { let mut out: Vec<(u32, [f32; 4])> = Vec::new(); @@ -656,4 +691,24 @@ mod tests { word(&mut v, "每个字", 10.0, 10.0, 9.0); assert_eq!(layout(v).plain_text(), "每个字"); } + + /// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends. + #[test] + fn words_and_lines_around_a_glyph() { + let t = PageText { + glyphs: Vec::from(["a", "b", " ", "c", "d", "e", "f", "g"].map(|s| TextGlyph { text: s.into(), rect: [0.0; 4] })), + line_of: vec![0, 0, 0, 0, 0, 1, 1, 1], + space_before: vec![false, false, false, false, false, false, true, false], + }; + assert_eq!(t.word_at(1), Some((0, 1))); + assert_eq!(t.word_at(3), Some((3, 4))); + assert_eq!(t.word_at(4), Some((3, 4))); + assert_eq!(t.word_at(5), Some((5, 5))); + assert_eq!(t.word_at(7), Some((6, 7))); + assert_eq!(t.line_at(2), Some((0, 4))); + assert_eq!(t.line_at(6), Some((5, 7))); + assert_eq!(t.word_at(8), None); + assert_eq!(t.line_at(8), None); + assert_eq!(PageText::default().line_at(0), None); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a54dff087..4030d7c6e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -222,6 +222,8 @@ pub struct DocView { pub(crate) text_failed: HashSet, pub find: Option, selection: Option, + /// Clicks in the current run on the text layer: two select a word, three the line, four the page. + clicks: u32, last_queue: Vec, viewport_w: f32, viewport_h: f32, @@ -363,6 +365,7 @@ impl DocView { text_failed: HashSet::new(), find: None, selection: None, + clicks: 0, last_queue: Vec::new(), viewport_w: 800.0, viewport_h: 600.0, @@ -1701,20 +1704,28 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { { sel.head = h; } - if resp.double_clicked() && over_text { - if let Some(a) = text.nearest(vx, vy) { - // Expand to the word: stop at inferred spaces, explicit spaces and line ends. - let is_break = |k: usize| text.glyphs[k].text.trim().is_empty(); - let mut s0 = a; - while s0 > 0 && !text.space_before[s0] && text.line_of[s0 - 1] == text.line_of[s0] && !is_break(s0 - 1) { - s0 -= 1; - } - let mut e = a; - while e + 1 < text.glyphs.len() && !text.space_before[e + 1] && text.line_of[e + 1] == text.line_of[e] && !is_break(e + 1) - { - e += 1; - } - view.selection = Some(Selection { page: i, anchor: s0, head: e }); + // Quick clicks widen the selection: two a word, three the line, four the page. + // egui counts up to three, so four come from the run kept here. The markup and + // Redact tools mark a double-clicked word at once, so they stop at the word. + let clicks = if resp.triple_clicked() { + view.clicks.max(2).saturating_add(1) + } else if resp.double_clicked() { + 2 + } else { + u32::from(resp.clicked()) + }; + if clicks > 0 { + view.clicks = clicks; + } + let clicks = if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks }; + if clicks >= 2 && over_text { + let span = text.nearest(vx, vy).and_then(|a| match clicks { + 2 => text.word_at(a), + 3 => text.line_at(a), + _ => text.glyphs.len().checked_sub(1).map(|last| (0, last)), + }); + if let Some((first, last)) = span { + view.selection = Some(Selection { page: i, anchor: first, head: last }); } } else if resp.clicked() && !over_link { view.selection = None; diff --git a/crates/ui-egui/tests/text_select.rs b/crates/ui-egui/tests/text_select.rs new file mode 100644 index 000000000..c2beef929 --- /dev/null +++ b/crates/ui-egui/tests/text_select.rs @@ -0,0 +1,86 @@ +//! Issue #295: quick clicks on text widen the selection, as in Acrobat: two clicks select a +//! word, three the line, four all the text on the page. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use pdfcraft_ui_egui::PdfCraftApp; + +/// One 300×200 page with two lines of Helvetica text. +fn two_lines() -> Vec { + let content = "BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj 0 -24 Td (jumps over the dog) Tj ET"; + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ) + .into_bytes() +} + +/// The page on screen and the pointer over the "u" of "quick". Frames are 1/60 s: a click takes +/// two frames (press, release), and egui counts a double-click only within 0.3 s. +fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> (Harness<'static, PdfCraftApp>, Pos2) { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.open_bytes("lines.pdf", None, two_lines()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("author", "Tester").unwrap(); + setup(&mut app); + app + }); + for _ in 0..200 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let at = h.state().views[0].glyph_screen_pos(0, 5).expect("text layer loaded"); + h.hover_at(at); + h.run_steps(1); + (h, at) +} + +/// Press and release without moving or leaving the page. +fn click(h: &mut Harness<'static, PdfCraftApp>, pos: Pos2) { + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(1); +} + +#[test] +fn quick_clicks_select_a_word_then_the_line_then_the_page() { + let (mut h, at) = harness(|_| {}); + let page = "The quick brown fox\njumps over the dog"; + for (n, want) in [None, Some("quick"), Some("The quick brown fox"), Some(page), Some(page)].into_iter().enumerate() { + click(&mut h, at); + assert_eq!(h.state().views[0].selected_text().as_deref(), want, "after {} clicks", n + 1); + } + // A pause ends the run: the next double-click selects a word again. + h.run_steps(60); + click(&mut h, at); + click(&mut h, at); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick")); +} + +#[test] +fn quick_clicks_with_the_highlighter_mark_only_the_word() { + let (mut h, at) = harness(|app| assert!(app.execute("comment.highlight"))); + for _ in 0..4 { + click(&mut h, at); + } + h.run_steps(3); + let app = h.state(); + let doc = app.session.get(app.views[0].id).unwrap(); + let marks: Vec<(&str, usize)> = doc.info.annotations.iter().map(|a| (a.subtype.as_str(), a.quads.len())).collect(); + assert_eq!(marks, [("Highlight", 1)], "one highlight, on the double-clicked word"); + assert!(app.views[0].selected_text().is_none()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0d7744569..0da89682e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1466,8 +1466,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"] -notes = "" +evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word"] +notes = "Drag to select; with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). The markup and Redact tools mark a double-clicked word and stop there. Missing: paragraph selection, and ⌘A across the whole document (it selects the current page)." [[feature]] id = "view.copy-text"