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:
|
/// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`].
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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 v1–v5 ladder —
|
||||||
// `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:
|
|
||||||
// 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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue