From b90875466e4e20592c5a7b9667ab5fbc248d4e16 Mon Sep 17 00:00:00 2001 From: krinry <121806532+krinry@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:35:12 +0530 Subject: [PATCH] Select tool: trigger multi-click text selection on pointer press (#1024) (#1040) * Select tool: trigger multi-click text selection on pointer press (#1024) - Detect consecutive primary pointer presses on page text using press timing and distance thresholds, highlighting words, lines, or the whole page immediately on the down-stroke rather than waiting for pointer release. - Keep selection intact on release for multi-clicks while allowing single clicks to clear or shift-extend. - Add regression test `multiclick_selects_on_pointer_press_before_release` in `crates/ui-egui/tests/text_select.rs`. - Credit krinry in contributors/people.toml. * Count presses with egui's own double-click delay and distance --------- Co-authored-by: Brandon Thomas --- contributors/people.toml | 4 ++ crates/ui-egui/src/canvas.rs | 73 ++++++++++++++++++++--------- crates/ui-egui/tests/text_select.rs | 25 ++++++++++ 3 files changed, 81 insertions(+), 21 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index c88e9159..c76ac2f7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -75,6 +75,10 @@ sync_github_name = true [people.jay-tau] sync_github_name = true +[people.krinry] +sync_github_name = true + + [people.arcanii] real_name = "Bryan Mark" display_name = "Arcanii" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 87cb30ff..35bc1b38 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -343,6 +343,8 @@ pub struct DocView { selection: Option, /// Clicks in the current run on the text layer: two select a word, three the line, four the page. clicks: u32, + last_press_time: f64, + last_press_pos: Option, last_queue: Vec, viewport_w: f32, viewport_h: f32, @@ -569,6 +571,8 @@ impl DocView { find: None, selection: None, clicks: 0, + last_press_time: 0.0, + last_press_pos: None, last_queue: Vec::new(), viewport_w: 800.0, viewport_h: 600.0, @@ -2465,14 +2469,40 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.ctx().set_cursor_icon(egui::CursorIcon::Text); } let press_here = ui.input(|inp| inp.pointer.press_origin()).is_some_and(|o| r.contains(o)); + let pressed = ui.input(|inp| inp.pointer.primary_pressed()); + if pressed && press_here && !over_link { + let now = ui.input(|inp| inp.time); + // The same limits egui uses to count clicks on release. + let (max_delay, max_dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); + let is_consecutive = + now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(p) <= max_dist); + view.clicks = if is_consecutive { view.clicks.saturating_add(1) } else { 1 }; + view.last_press_time = now; + view.last_press_pos = Some(p); + + // 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)), + }); + if let Some((first, last)) = span { + view.selection = Some(Selection::run(i, first, last)); + } + } + } if resp.drag_started() && press_here && !over_link { let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p); let (ox, oy) = xf.screen_to_view(origin); - view.selection = if column { - Some(Selection { page: i, span: Span::Column { rect: [ox, oy, vx, vy] } }) - } else { - text.nearest(ox, oy).map(|a| Selection::run(i, a, a)) - }; + if view.clicks < 2 { + view.selection = if column { + Some(Selection { page: i, span: Span::Column { rect: [ox, oy, vx, vy] } }) + } else { + text.nearest(ox, oy).map(|a| Selection::run(i, a, a)) + }; + } } if resp.dragged() && let Some(sel) = view.selection.as_mut().filter(|s| s.page == i) @@ -2496,29 +2526,30 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.selection = None; } // 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() { + // Release-based fallback for platforms or synthetic events that report double_clicked. + let release_clicks = if resp.triple_clicked() { view.clicks.max(2).saturating_add(1) } else if resp.double_clicked() { - 2 + view.clicks.max(2) } else { - u32::from(resp.clicked()) + 0 }; - if clicks > 0 { - view.clicks = clicks; + if release_clicks > 0 { + view.clicks = release_clicks; } - let clicks = if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks }; + 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)), - }); - if let Some((first, last)) = span { - view.selection = Some(Selection::run(i, first, last)); + 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)), + }); + if let Some((first, last)) = span { + view.selection = Some(Selection::run(i, first, last)); + } } - } else if resp.clicked() && !over_link { + } else if resp.clicked() && view.clicks < 2 && !over_link { // ⇧-click extends a selection on this page to the click, keeping its // anchor (#527). Otherwise (no shift, no selection on this page, or a // column selection, which has no anchor glyph; a selection cannot yet diff --git a/crates/ui-egui/tests/text_select.rs b/crates/ui-egui/tests/text_select.rs index 342668b5..fc8d19a8 100644 --- a/crates/ui-egui/tests/text_select.rs +++ b/crates/ui-egui/tests/text_select.rs @@ -139,3 +139,28 @@ fn shift_click_extends_the_selection_from_its_anchor() { shift_click(&mut h, n); assert_eq!(h.state().views[0].selected_text(), None); } + +/// Issue #1024: multi-click selection triggers immediately on pointer press, before release. +#[test] +fn multiclick_selects_on_pointer_press_before_release() { + let (mut h, at) = harness(|_| {}); + // First click: press and release. + click(&mut h, at); + // Second click: press and HOLD (do not release). + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"), "word highlights on press"); + // Release the second click: still selected. + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"), "word stays selected on release"); + + // Third click: press and HOLD. + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox"), "line highlights on press"); + // Release third click. + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox"), "line stays selected on release"); +}