From a358df8cf267ff1f238a4c7ee5fd29268572967a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 12 Jun 2026 12:24:08 -0400 Subject: [PATCH] triple-click selects the line (Q#M4, protocol v7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PointerKind::TripleDown — the cheap additive bump shape returns: PROTOCOL_VERSION 7, SUPPORTED [6, 7], the new variant kept off pre-v7 wires by a frontend send-gate that downgrades it to the plain Down a third click produced before. The GPU's click history deepens to a chain count (1 → Down, 2 → DoubleDown, 3 → TripleDown, then restart). Daemon side, select_line_at_cursor selects the line including its trailing newline, so consecutive triple-click lines abut. Co-Authored-By: Claude Fable 5 --- pmacs-gpu/src/main.rs | 51 +++++++++++++++++++++++++---------- pmacs-protocol/src/message.rs | 22 +++++++++++++-- src/editor.rs | 32 ++++++++++++++++++++++ src/editor_core.rs | 24 +++++++++++++++++ src/protocol.rs | 21 ++++++++------- 5 files changed, 124 insertions(+), 26 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index b25cd0c..13ea740 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -409,9 +409,11 @@ struct State { /// Hit byte of the last Pointer event sent — Drag coalescing: /// pixel-rate motion only ships when the hit byte changes. last_pointer_sent_byte: Option, - /// `(when, byte)` of the last primary Down, for frontend-side - /// double-click detection (same-hit within the interval). - last_pointer_down: Option<(std::time::Instant, u64)>, + /// `(when, byte, chain_count)` of the last primary Down, for + /// frontend-side multi-click detection (same-hit within the + /// interval): count 1 = single, 2 = the double already fired, + /// so the next same-hit press is a triple (Q#M4). + last_pointer_down: Option<(std::time::Instant, u64, u8)>, /// Q#R2 — the per-line surgery path skips rebuilding the pointer /// hit map (clicks are rare next to keystrokes); this marks it /// stale so `hit_test_source_byte` rebuilds on demand from the @@ -472,6 +474,15 @@ impl App { if client.server_protocol_version() < 5 { return; } + // TripleDown is a v7 variant; a pre-v7 instance would + // hard-error decoding it. Downgrade to a plain Down — the + // exact behavior the third click had before v7 (the chain + // restarting). + let kind = if kind == PointerKind::TripleDown && client.server_protocol_version() < 7 { + PointerKind::Down + } else { + kind + }; if let Err(e) = client.send_pointer(buffer_id, byte, kind, mods) { eprintln!("pmacs-gpu: send_pointer failed: {e}"); } @@ -2069,8 +2080,9 @@ impl State { self.optimistic_floor_set_at = None; } - /// Frontend-side double-click detection: a second Down at the - /// same hit byte within the interval upgrades to `DoubleDown`. + /// Frontend-side multi-click detection: a second Down at the + /// same hit byte within the interval upgrades to `DoubleDown`, + /// a third to `TripleDown` (Q#M4); a fourth restarts the chain. fn classify_pointer_down(&mut self, byte: u64, shift: bool) -> PointerKind { if shift { // Shift-click extends the selection (Q#M5); it neither @@ -2080,15 +2092,26 @@ impl State { 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 - }); - if is_double { - // A third click starts over (triple-click is deferred). - PointerKind::DoubleDown - } else { - self.last_pointer_down = Some((now, byte)); - PointerKind::Down + let prior_chain = self + .last_pointer_down + .take() + .and_then(|(at, prev, count)| { + (prev == byte && now.duration_since(at) <= DOUBLE_CLICK_WINDOW).then_some(count) + }) + .unwrap_or(0); + match prior_chain { + 0 => { + self.last_pointer_down = Some((now, byte, 1)); + PointerKind::Down + } + 1 => { + self.last_pointer_down = Some((now, byte, 2)); + PointerKind::DoubleDown + } + _ => { + // Chain consumed: a fourth click starts over. + PointerKind::TripleDown + } } } diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 7f2963c..e82bb91 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -352,6 +352,12 @@ pub enum PointerKind { /// Second press at the same hit within the frontend's /// double-click window — selects the word at `byte`. DoubleDown, + /// Third press at the same hit within the frontend's + /// multi-click window — selects the whole line at `byte`, + /// trailing newline included (Q#M4, protocol v7). The frontend + /// sends this only to a `>= 7` instance; against an older one + /// the third click restarts the chain as a plain `Down`. + TripleDown, } impl FrontendEvent { @@ -1031,7 +1037,14 @@ pub enum ResourceBody { /// only v6 peers: a version-mismatched pair fails the handshake with /// [`GoodbyeReason::VersionMismatch`] instead of garbling cell /// traffic mid-session. -pub const PROTOCOL_VERSION: u32 = 6; +/// +/// Q#M4 (mouse deferred set): bumped from 6 to 7 for +/// [`PointerKind::TripleDown`]. Back to the cheap additive shape: +/// a new variant on a frontend→instance enum, gated in the frontend +/// (sent only when the instance's `Hello.protocol_version >= 7`), +/// so the compat ladder restarts on the v6 encoding floor — +/// `SUPPORTED_PROTOCOL_VERSIONS` grows to `[6, 7]`. +pub const PROTOCOL_VERSION: u32 = 7; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1063,7 +1076,12 @@ pub const PROTOCOL_VERSION: u32 = 6; /// shared-struct encodings never changed; this bump is the first /// that breaks that assumption, and slice membership is how the /// handshake communicates it. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6]; +/// +/// Q#M4: extended to `[6, 7]`. `PointerKind::TripleDown` is additive +/// and frontend-gated (like `Pointer` itself at v5), so the ladder +/// resumes: v6 and v7 binaries interoperate, with the new variant +/// kept off wires whose instance negotiated `< 7`. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. diff --git a/src/editor.rs b/src/editor.rs index 6f4bbcc..5016b76 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -796,6 +796,8 @@ impl EditorState { /// * `Up` collapses an empty selection (a click without drag). /// * `DoubleDown` selects the word at the hit (frontend-side /// double-click detection — only it knows pixel proximity). + /// * `TripleDown` selects the whole line at the hit, trailing + /// newline included (Q#M4, protocol v7). /// /// 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. @@ -862,6 +864,12 @@ impl EditorState { aw.goal_col = None; core.select_word_at_cursor(); } + PointerKind::TripleDown => { + let aw = core.active_window_mut(); + aw.cursor = byte; + aw.goal_col = None; + core.select_line_at_cursor(); + } } } @@ -4452,6 +4460,30 @@ mod tests { assert_eq!(s.core.borrow().cursor(), 15, "mismatched buffer ignored"); } + #[test] + fn dispatch_pointer_triple_down_selects_the_whole_line() { + use crate::protocol::{Modifiers as WireMods, PointerKind}; + // Line 0 = bytes [0, 12) including the newline; line 1 = + // [12, 19). + let mut s = fresh_with(b"hello world\nsecond\n"); + let bid = s.core.borrow().active_buffer_id(); + let none = WireMods::NONE; + + s.dispatch_pointer(FrontendId::LOCAL, bid, 4, PointerKind::TripleDown, none); + assert_eq!( + s.core.borrow().active_region(), + Some((0, 12)), + "whole line selected, trailing newline included" + ); + assert_eq!(s.core.borrow().cursor(), 12, "cursor at selection end"); + + // A line without a trailing newline runs to the buffer end. + let mut s = fresh_with(b"abc"); + let bid = s.core.borrow().active_buffer_id(); + s.dispatch_pointer(FrontendId::LOCAL, bid, 1, PointerKind::TripleDown, none); + assert_eq!(s.core.borrow().active_region(), Some((0, 3))); + } + #[test] fn dispatch_pointer_shift_down_extends_instead_of_restarting() { use crate::protocol::{Modifiers as WireMods, PointerKind}; diff --git a/src/editor_core.rs b/src/editor_core.rs index c73e05a..2ccc500 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -779,6 +779,30 @@ impl EditorCore { true } + /// Select the whole line at the active cursor, trailing newline + /// included — the convention that makes consecutive triple-click + /// lines abut (Q#M4). The cursor lands at the selection end (the + /// start of the next line). No-op when the buffer is gone. + pub fn select_line_at_cursor(&mut self) { + let id = self.active_buffer_id(); + let cursor = self.active_window().cursor; + let (start, end) = { + let reg = self.registry.borrow(); + let Ok(buffer) = reg.get(id) else { + return; + }; + let view = &self.active_window().text_view; + let line = view.line_at_offset(cursor); + let start = view.line_offset(line).unwrap_or(0); + let end = view.line_offset(line + 1).unwrap_or_else(|| buffer.len()); + (start, end) + }; + let aw = self.active_window_mut(); + aw.selection = Some(crate::window::Selection { anchor: start }); + aw.cursor = end; + aw.goal_col = None; + } + /// Move the cursor forward to the next paragraph break. /// /// A paragraph break is a blank line (empty or whitespace-only). diff --git a/src/protocol.rs b/src/protocol.rs index 8776044..4d75e84 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_six_for_underline_color() { + fn protocol_version_is_seven_for_triple_click() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1691,24 +1691,25 @@ mod tests { // The mouse framing Q#M1 bumped 4→5 (FrontendEvent::Pointer). // T M4.6 bumped 5→6 (`Style::underline_color`) — the first // bump that changed an existing struct's postcard encoding, - // so v6 binaries serve v6 sessions only. - assert_eq!(PROTOCOL_VERSION, 6); + // making v6 the ladder's encoding floor. Q#M4 bumped 6→7 + // (`PointerKind::TripleDown`, additive + frontend-gated). + assert_eq!(PROTOCOL_VERSION, 7); } #[test] - fn supported_protocol_versions_is_exactly_v6() { + fn supported_protocol_versions_resume_ladder_on_v6_floor() { // T M4.6: `Style::underline_color` changed the encoding of - // every cell-carrying message (`Cell` / `CellDelta` / - // `Snapshot` / `StyleSpans`). The v1–v5 compat ladder relied - // on shared-struct encodings never changing — additive enum - // variants filtered per session — so the ladder ends here: + // every cell-carrying message, ending the v1–v5 ladder — // pre-v6 peers are refused at the handshake (a clean // VersionMismatch) rather than garbling postcard mid-session. + // Q#M4: the ladder resumes above that floor — v7 is additive + // (`TripleDown`, frontend-gated), so v6 and v7 interoperate. assert!(is_supported_protocol_version(6)); - for rejected in [0, 1, 2, 3, 4, 5, 7, u32::MAX] { + assert!(is_supported_protocol_version(7)); + for rejected in [0, 1, 2, 3, 4, 5, 8, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v6 binary" + "v{rejected} must be rejected by a v7 binary" ); } }