Issue #295: three clicks select the line, four clicks the page (#366)

* Text: find the word and the line around a glyph

PageText gets word_at(i) and line_at(i). Each returns the first and
last glyph of the word or the line around glyph i, or None when i is
past the end of the page's text. A word stops at a word gap, a blank
glyph or a line end. A line is the run of glyphs with the same line
number.

The canvas's double-click used its own loop for this, which indexed the
glyph, line and gap arrays directly. It now calls word_at, which reads
them with get(). Double-clicking selects the same words as before.

Test: render words_and_lines_around_a_glyph, on two lines with a blank
glyph, a word gap and a line break.

* Issue #295: three clicks select the line, four clicks the page

With the Select tool, quick clicks on text widen the selection like
Acrobat: two clicks select a word (as before), three the line, four or
more all the text on the page. A third click used to clear the
selection.

egui counts only up to triple clicks and reports a fourth quick click
as another triple, so the view keeps its own count of the current run.
A single click resets it and a pause ends it.

With the markup tools (Highlight and the like) and Redact, a
double-click already turns the word into a mark at once. They stop at
the word, so more clicks don't stack line or page marks.

Tests: ui-egui quick_clicks_select_a_word_then_the_line_then_the_page
and quick_clicks_with_the_highlighter_mark_only_the_word.

parity: view.text-select cites the tests. The notes say what is
missing: paragraph selection, and Ctrl/⌘+A across the whole document
(it selects the current page).

* Clippy: as_chunks in the signature preview test (follow-up to #355)

Clippy 1.99 rejects chunks_exact with a constant chunk size
(chunks_exact_to_as_chunks), so `cargo clippy --workspace --all-targets
-- -D warnings` fails on main since #355. Use as_chunks, as #360 did
for the test above it.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
pauliusiv123-rgb
2026-10-08 20:04:10 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 31f3631868
commit 85dcb9dece
4 changed files with 168 additions and 16 deletions
+55
View File
@@ -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<usize>) -> 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);
}
}
+25 -14
View File
@@ -222,6 +222,8 @@ pub struct DocView {
pub(crate) text_failed: HashSet<usize>,
pub find: Option<Find>,
selection: Option<Selection>,
/// Clicks in the current run on the text layer: two select a word, three the line, four the page.
clicks: u32,
last_queue: Vec<RenderRequest>,
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;
+86
View File
@@ -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<u8> {
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());
}
+2 -2
View File
@@ -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"