Shift-click extends the selection (Q#M5)

dispatch_pointer consults the mods it has carried since v5: a Down
with SHIFT keeps the existing anchor (or, with no selection,
anchors at the pre-click cursor) and only moves the cursor — the
universal extend convention. Zero wire change. Frontend-side, a
Shift-click neither advances nor inherits the multi-click chain,
so two Shift-clicks can't become a word select.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-12 12:16:08 -04:00
parent aea08a58ec
commit e8e494e1c1
2 changed files with 65 additions and 7 deletions

View File

@ -635,7 +635,8 @@ impl ApplicationHandler<AppEvent> for App {
let Some(byte) = state.hit_test_source_byte(x, y) else {
return;
};
let kind = state.classify_pointer_down(byte);
let kind =
state.classify_pointer_down(byte, mods.contains(Modifiers::SHIFT));
state.pointer_drag_active = true;
state.last_pointer_sent_byte = Some(byte);
state.note_pointer_round_trip();
@ -2070,7 +2071,14 @@ impl State {
/// Frontend-side double-click detection: a second Down at the
/// same hit byte within the interval upgrades to `DoubleDown`.
fn classify_pointer_down(&mut self, byte: u64) -> PointerKind {
fn classify_pointer_down(&mut self, byte: u64, shift: bool) -> PointerKind {
if shift {
// Shift-click extends the selection (Q#M5); it neither
// advances nor inherits the multi-click chain — two
// Shift-clicks must not become a word select.
self.last_pointer_down = None;
return PointerKind::Down;
}
let now = std::time::Instant::now();
let is_double = self.last_pointer_down.take().is_some_and(|(at, prev)| {
prev == byte && now.duration_since(at) <= DOUBLE_CLICK_WINDOW

View File

@ -787,7 +787,11 @@ impl EditorState {
/// inline adornments, scroll), so no cell geometry is consulted.
///
/// * `Down` places the cursor and anchors a selection there
/// (a following drag grows it).
/// (a following drag grows it). With SHIFT it *extends*
/// instead (Q#M5): the existing anchor — or, with no
/// selection, the pre-click cursor — is kept and only the
/// cursor moves, matching the universal Shift-click
/// convention.
/// * `Drag` moves the cursor; the anchor stays.
/// * `Up` collapses an empty selection (a click without drag).
/// * `DoubleDown` selects the word at the hit (frontend-side
@ -795,15 +799,13 @@ impl EditorState {
///
/// The hit byte is clamped into the buffer and snapped back to a
/// UTF-8 boundary: the frontend's hit may race an in-flight edit.
/// `mods` is carried for future Shift-click extension and ignored
/// today, matching `dispatch_mouse`.
pub fn dispatch_pointer(
&mut self,
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
byte: u64,
kind: crate::protocol::PointerKind,
_mods: crate::protocol::Modifiers,
mods: crate::protocol::Modifiers,
) {
use crate::protocol::PointerKind;
let mut core = self.core.borrow_mut();
@ -828,10 +830,19 @@ impl EditorState {
};
match kind {
PointerKind::Down => {
let prev_cursor = core.active_window().cursor;
let extending = mods.contains(crate::protocol::Modifiers::SHIFT);
let keep_anchor = extending && core.active_window().selection.is_some();
let aw = core.active_window_mut();
aw.cursor = byte;
aw.goal_col = None;
core.begin_selection(byte);
if extending {
if !keep_anchor {
core.begin_selection(prev_cursor);
}
} else {
core.begin_selection(byte);
}
}
PointerKind::Drag => {
let aw = core.active_window_mut();
@ -4441,6 +4452,45 @@ mod tests {
assert_eq!(s.core.borrow().cursor(), 15, "mismatched buffer ignored");
}
#[test]
fn dispatch_pointer_shift_down_extends_instead_of_restarting() {
use crate::protocol::{Modifiers as WireMods, PointerKind};
let mut s = fresh_with(b"hello world\n");
let bid = s.core.borrow().active_buffer_id();
let none = WireMods::NONE;
let shift = WireMods::SHIFT;
// No selection, cursor parked at 2: Shift-Down anchors at the
// pre-click cursor and moves to the hit (Q#M5).
s.dispatch_pointer(FrontendId::LOCAL, bid, 2, PointerKind::Down, none);
s.dispatch_pointer(FrontendId::LOCAL, bid, 2, PointerKind::Up, none);
assert!(s.core.borrow().active_region().is_none());
s.dispatch_pointer(FrontendId::LOCAL, bid, 7, PointerKind::Down, shift);
assert_eq!(s.core.borrow().active_region(), Some((2, 7)));
// The Up after a Shift-click must not collapse the region
// (anchor ≠ cursor).
s.dispatch_pointer(FrontendId::LOCAL, bid, 7, PointerKind::Up, shift);
assert_eq!(s.core.borrow().active_region(), Some((2, 7)));
// With a live selection, Shift-Down keeps the anchor — even
// extending in the other direction.
s.dispatch_pointer(FrontendId::LOCAL, bid, 0, PointerKind::Down, shift);
assert_eq!(
s.core.borrow().active_region(),
Some((0, 2)),
"anchor 2 kept; cursor crossed to the other side"
);
// A drag after a Shift-Down grows from the inherited anchor.
s.dispatch_pointer(FrontendId::LOCAL, bid, 9, PointerKind::Drag, shift);
assert_eq!(s.core.borrow().active_region(), Some((2, 9)));
// A plain Down restarts the anchor as before.
s.dispatch_pointer(FrontendId::LOCAL, bid, 4, PointerKind::Down, none);
s.dispatch_pointer(FrontendId::LOCAL, bid, 4, PointerKind::Up, none);
assert!(s.core.borrow().active_region().is_none());
}
/// Acceptance bullet 3: mouse events are coalesced at frame
/// boundaries — many drag events between renders all apply, and
/// the cursor ends up at the last position.