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 <echelon@gmail.com>
This commit is contained in:
krinry 2026-10-11 23:35:12 +05:30 committed by GitHub
parent 575f99f9be
commit b90875466e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 81 additions and 21 deletions

View File

@ -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"

View File

@ -343,6 +343,8 @@ pub struct DocView {
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_press_time: f64,
last_press_pos: Option<Pos2>,
last_queue: Vec<RenderRequest>,
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

View File

@ -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");
}