diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index ad17dc3..b25cd0c 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -635,7 +635,8 @@ impl ApplicationHandler 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 diff --git a/src/editor.rs b/src/editor.rs index 91ca5cb..6f4bbcc 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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.