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:
parent
e8e494e1c1
commit
a358df8cf2
|
|
@ -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<u64>,
|
||||
/// `(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue