triple-click selects the line (Q#M4, protocol v7)

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 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-12 12:24:08 -04:00
parent e8e494e1c1
commit a358df8cf2
5 changed files with 124 additions and 26 deletions

View File

@ -409,9 +409,11 @@ struct State {
/// Hit byte of the last Pointer event sent — Drag coalescing: /// Hit byte of the last Pointer event sent — Drag coalescing:
/// pixel-rate motion only ships when the hit byte changes. /// pixel-rate motion only ships when the hit byte changes.
last_pointer_sent_byte: Option<u64>, last_pointer_sent_byte: Option<u64>,
/// `(when, byte)` of the last primary Down, for frontend-side /// `(when, byte, chain_count)` of the last primary Down, for
/// double-click detection (same-hit within the interval). /// frontend-side multi-click detection (same-hit within the
last_pointer_down: Option<(std::time::Instant, u64)>, /// 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 /// Q#R2 — the per-line surgery path skips rebuilding the pointer
/// hit map (clicks are rare next to keystrokes); this marks it /// hit map (clicks are rare next to keystrokes); this marks it
/// stale so `hit_test_source_byte` rebuilds on demand from the /// stale so `hit_test_source_byte` rebuilds on demand from the
@ -472,6 +474,15 @@ impl App {
if client.server_protocol_version() < 5 { if client.server_protocol_version() < 5 {
return; 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) { if let Err(e) = client.send_pointer(buffer_id, byte, kind, mods) {
eprintln!("pmacs-gpu: send_pointer failed: {e}"); eprintln!("pmacs-gpu: send_pointer failed: {e}");
} }
@ -2069,8 +2080,9 @@ impl State {
self.optimistic_floor_set_at = None; self.optimistic_floor_set_at = None;
} }
/// Frontend-side double-click detection: a second Down at the /// Frontend-side multi-click detection: a second Down at the
/// same hit byte within the interval upgrades to `DoubleDown`. /// 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 { fn classify_pointer_down(&mut self, byte: u64, shift: bool) -> PointerKind {
if shift { if shift {
// Shift-click extends the selection (Q#M5); it neither // Shift-click extends the selection (Q#M5); it neither
@ -2080,15 +2092,26 @@ impl State {
return PointerKind::Down; return PointerKind::Down;
} }
let now = std::time::Instant::now(); let now = std::time::Instant::now();
let is_double = self.last_pointer_down.take().is_some_and(|(at, prev)| { let prior_chain = self
prev == byte && now.duration_since(at) <= DOUBLE_CLICK_WINDOW .last_pointer_down
}); .take()
if is_double { .and_then(|(at, prev, count)| {
// A third click starts over (triple-click is deferred). (prev == byte && now.duration_since(at) <= DOUBLE_CLICK_WINDOW).then_some(count)
PointerKind::DoubleDown })
} else { .unwrap_or(0);
self.last_pointer_down = Some((now, byte)); match prior_chain {
PointerKind::Down 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
}
} }
} }

View File

@ -352,6 +352,12 @@ pub enum PointerKind {
/// Second press at the same hit within the frontend's /// Second press at the same hit within the frontend's
/// double-click window — selects the word at `byte`. /// double-click window — selects the word at `byte`.
DoubleDown, 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 { impl FrontendEvent {
@ -1031,7 +1037,14 @@ pub enum ResourceBody {
/// only v6 peers: a version-mismatched pair fails the handshake with /// only v6 peers: a version-mismatched pair fails the handshake with
/// [`GoodbyeReason::VersionMismatch`] instead of garbling cell /// [`GoodbyeReason::VersionMismatch`] instead of garbling cell
/// traffic mid-session. /// 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 /// 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 /// 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 /// shared-struct encodings never changed; this bump is the first
/// that breaks that assumption, and slice membership is how the /// that breaks that assumption, and slice membership is how the
/// handshake communicates it. /// 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 /// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -796,6 +796,8 @@ impl EditorState {
/// * `Up` collapses an empty selection (a click without drag). /// * `Up` collapses an empty selection (a click without drag).
/// * `DoubleDown` selects the word at the hit (frontend-side /// * `DoubleDown` selects the word at the hit (frontend-side
/// double-click detection — only it knows pixel proximity). /// 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 /// 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. /// UTF-8 boundary: the frontend's hit may race an in-flight edit.
@ -862,6 +864,12 @@ impl EditorState {
aw.goal_col = None; aw.goal_col = None;
core.select_word_at_cursor(); 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"); 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] #[test]
fn dispatch_pointer_shift_down_extends_instead_of_restarting() { fn dispatch_pointer_shift_down_extends_instead_of_restarting() {
use crate::protocol::{Modifiers as WireMods, PointerKind}; use crate::protocol::{Modifiers as WireMods, PointerKind};

View File

@ -779,6 +779,30 @@ impl EditorCore {
true 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. /// Move the cursor forward to the next paragraph break.
/// ///
/// A paragraph break is a blank line (empty or whitespace-only). /// A paragraph break is a blank line (empty or whitespace-only).

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips --- // --- M5.5a handshake & postcard round-trips ---
#[test] #[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 / // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6 // SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1691,24 +1691,25 @@ mod tests {
// The mouse framing Q#M1 bumped 4→5 (FrontendEvent::Pointer). // The mouse framing Q#M1 bumped 4→5 (FrontendEvent::Pointer).
// T M4.6 bumped 5→6 (`Style::underline_color`) — the first // T M4.6 bumped 5→6 (`Style::underline_color`) — the first
// bump that changed an existing struct's postcard encoding, // bump that changed an existing struct's postcard encoding,
// so v6 binaries serve v6 sessions only. // making v6 the ladder's encoding floor. Q#M4 bumped 6→7
assert_eq!(PROTOCOL_VERSION, 6); // (`PointerKind::TripleDown`, additive + frontend-gated).
assert_eq!(PROTOCOL_VERSION, 7);
} }
#[test] #[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 // T M4.6: `Style::underline_color` changed the encoding of
// every cell-carrying message (`Cell` / `CellDelta` / // every cell-carrying message, ending the v1v5 ladder —
// `Snapshot` / `StyleSpans`). The v1v5 compat ladder relied
// on shared-struct encodings never changing — additive enum
// variants filtered per session — so the ladder ends here:
// pre-v6 peers are refused at the handshake (a clean // pre-v6 peers are refused at the handshake (a clean
// VersionMismatch) rather than garbling postcard mid-session. // 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)); 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!( assert!(
!is_supported_protocol_version(rejected), !is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v6 binary" "v{rejected} must be rejected by a v7 binary"
); );
} }
} }