Fix #527: Shift+click extends the text selection (#560)

A plain click on a page cleared the text selection whatever modifiers were
held, so there was no way to extend a selection with Shift+click as in
Acrobat. Now, when Shift is held and there is a text selection on the
clicked page, the click moves the selection head to the nearest text
position and keeps the anchor. Without Shift, or with no selection on that
page, a click still clears the selection. Selections remain single-page:
cross-page selection (the issue's second request) is not part of this
change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-09 11:48:50 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e5b37b8b9e
commit 9acb29e418
3 changed files with 64 additions and 3 deletions
+8 -1
View File
@@ -1758,7 +1758,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.selection = Some(Selection { page: i, anchor: first, head: last });
}
} else if resp.clicked() && !over_link {
view.selection = None;
// ⇧-click extends a selection on this page to the click, keeping its
// anchor (#527). Otherwise (no shift, or no selection on this page; a
// selection cannot yet span pages) a click clears the selection.
let shift = ui.input(|inp| inp.modifiers.shift);
match (view.selection.as_mut().filter(|s| shift && s.page == i), text.nearest(vx, vy)) {
(Some(sel), Some(h)) => sel.head = h,
_ => view.selection = None,
}
}
}
}
+54
View File
@@ -85,3 +85,57 @@ fn quick_clicks_with_the_highlighter_mark_only_the_word() {
assert_eq!(marks, [("Highlight", 1)], "one highlight, on the double-clicked word");
assert!(app.views[0].selected_text().is_none());
}
/// Press at `from`, move to `to` and release there.
fn drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) {
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + (to - from) * (k as f32 / 4.0));
h.run_steps(1);
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(1);
}
/// Click at `pos` with Shift held.
fn shift_click(h: &mut Harness<'static, PdfCraftApp>, pos: Pos2) {
h.event(Event::ModifiersChanged(Modifiers::SHIFT));
h.hover_at(pos);
h.run_steps(1);
for pressed in [true, false] {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT });
}
h.run_steps(1);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(1);
}
/// Issue #527: ⇧-click extends the text selection to the click, keeping its anchor.
#[test]
fn shift_click_extends_the_selection_from_its_anchor() {
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");
// Drag over "quick" (glyphs 4..=8).
let (q, k) = (glyph(&h, 4), glyph(&h, 8));
drag(&mut h, q, k);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
h.run_steps(60);
// ⇧-click on the "n" of "brown": the selection runs from the anchor to it.
let n = glyph(&h, 14);
shift_click(&mut h, n);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick brown"));
h.run_steps(60);
// ⇧-click before the anchor: the anchor stays, the selection now runs back to the click.
let t = glyph(&h, 0);
shift_click(&mut h, t);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The q"));
h.run_steps(60);
// A plain click still clears it.
click(&mut h, n);
assert_eq!(h.state().views[0].selected_text(), None);
h.run_steps(60);
// ⇧-click with nothing selected selects nothing.
shift_click(&mut h, n);
assert_eq!(h.state().views[0].selected_text(), None);
}
+2 -2
View File
@@ -1478,8 +1478,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", "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)."
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", "crates/ui-egui/tests/text_select.rs::shift_click_extends_the_selection_from_its_anchor"]
notes = "Drag to select; ⇧-click extends the selection to the click, keeping its anchor (same page only); 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: selection across pages (dragging or ⇧-clicking onto another page), paragraph selection, and ⌘A across the whole document (it selects the current page)."
[[feature]]
id = "view.copy-text"