From f48b76c146a011a04d13d08bfb2bb2a541713b5b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:20:09 +0200 Subject: [PATCH] Text selection: a drag after a double- or triple-click extends by whole words or lines (fixes #1685) (#1689) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 34 +++++++++++++------ crates/ui-egui/tests/ui_group/text_select.rs | 35 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2e23c2a97..7b66c79a7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -266,6 +266,16 @@ impl Selection { } } +/// The glyphs `clicks` quick clicks select around glyph `a`: two the word, three the line, more +/// the page. +fn click_unit(text: &PageText, clicks: u32, a: usize) -> Option<(usize, usize)> { + match clicks { + 2 => text.word_at(a), + 3 => text.line_at(a), + _ => text.glyphs.len().checked_sub(1).map(|last| (0, last)), + } +} + struct PageTex { tag: u64, tex: TextureHandle, @@ -2840,11 +2850,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Multi-click highlights immediately on pointer press instead of release (#1024). let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.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)), - }); + let span = text.nearest(vx, vy).and_then(|a| click_unit(&text, clicks, a)); if let Some((first, last)) = span { view.selection = Some(Selection::run(i, first, last)); } @@ -2862,10 +2868,22 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; } } + let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; if resp.dragged() && let Some(sel) = view.selection.as_mut().filter(|s| s.page == i) { match &mut sel.span { + // After a multi-click the drag extends by its unit (#1685), as in + // Acrobat and browsers: whole words or lines, always keeping the one + // clicked, which holds the anchor whichever way the drag went. + Span::Run { anchor, head } if clicks >= 2 => { + if let Some(h) = text.nearest(vx, vy) + && let Some((first, last)) = click_unit(&text, clicks, *anchor) + { + let (to_first, to_last) = click_unit(&text, clicks, h).unwrap_or((h, h)); + (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; + } + } Span::Run { head, .. } => { if let Some(h) = text.nearest(vx, vy) { *head = h; @@ -2901,11 +2919,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // (a highlight made by the double-click left its word selected). if release_clicks > 0 && clicks >= 2 && over_text { if view.selection.is_none() { - 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)), - }); + let span = text.nearest(vx, vy).and_then(|a| click_unit(&text, clicks, a)); if let Some((first, last)) = span { view.selection = Some(Selection::run(i, first, last)); } diff --git a/crates/ui-egui/tests/ui_group/text_select.rs b/crates/ui-egui/tests/ui_group/text_select.rs index b77d7f05a..e9e4ecdbd 100644 --- a/crates/ui-egui/tests/ui_group/text_select.rs +++ b/crates/ui-egui/tests/ui_group/text_select.rs @@ -314,3 +314,38 @@ fn a_selection_drag_held_at_the_bottom_edge_scrolls_the_view() { h.run_steps(2); assert_eq!(h.state().views[0].selected_text(), Some(held), "releasing keeps the selection"); } + +/// Click at `at`, then press there again and drag to `to` before releasing: a double-click drag. +fn double_click_drag(h: &mut Harness<'static, PdfCraftApp>, at: Pos2, to: Pos2) { + click(h, at); + press_and_move(h, at, to); + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); +} + +/// Issue #1685: dragging after a double-click extends the selection a word at a time, and the +/// double-clicked word stays selected whichever way the drag goes. +#[test] +fn a_double_click_drag_extends_by_whole_words_both_ways() { + let (mut h, _) = harness(|_| {}); + let glyph = |h: &Harness<'static, PdfCraftApp>, g: usize| h.state().views[0].glyph_screen_pos(0, g).expect("glyph on screen"); + // "The quick brown fox": the "u" of "quick" is glyph 5, the "o" of "brown" glyph 12. + let (u, o) = (glyph(&h, 5), glyph(&h, 12)); + double_click_drag(&mut h, u, o); + let right = h.state().views[0].selected_text(); + h.run_steps(60); + double_click_drag(&mut h, o, u); + let left = h.state().views[0].selected_text(); + assert_eq!([right.as_deref(), left.as_deref()], [Some("quick brown"); 2], "dragging right, then left"); +} + +/// Issue #1685: after a triple-click the drag extends a whole line at a time. +#[test] +fn a_triple_click_drag_extends_by_whole_lines() { + let (mut h, at) = harness(|_| {}); + // A glyph inside "jumps over the dog", on the second line. + let below = h.state().views[0].glyph_screen_pos(0, 23).expect("glyph on screen"); + click(&mut h, at); + double_click_drag(&mut h, at, below); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox\njumps over the dog")); +}