Merge pull request #66 from levineuwirth/session-mouse-deferred-set

Mouse deferred set: Shift-click, triple-click (v7), minimap jump, edge auto-scroll
This commit is contained in:
Levi Neuwirth 2026-06-12 13:28:35 -04:00 committed by GitHub
commit b4871ffe7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 567 additions and 39 deletions

View File

@ -0,0 +1,83 @@
# Mouse deferred set — framing pass
Date: 2026-06-12. PR #61 (mouse framing Q#M1M3) shipped click, drag,
double-click word select, and wheel scroll, deferring four gestures.
This session takes them. Survey facts: `dispatch_pointer`
(editor.rs:800) ignores `mods`; `classify_pointer_down`
(pmacs-gpu main.rs:2073) keeps a one-deep click history and a comment
promising triple-click; the minimap has pure geometry (x =
`width - MINIMAP_RIGHT - MINIMAP_WIDTH`, linear y→line) but clicks
over it fall through to text hit-testing; no repeating tick exists in
the winit loop; `begin_selection` / `select_word_at_cursor` are the
daemon's reusable primitives, and nothing daemon-side selects a line.
## Q#M4 — triple-click line select
**Stance: new `PointerKind::TripleDown`, protocol v7, daemon
`select_line_at_cursor`.** Gesture semantics stay daemon-side (the
Q#M2 contract): the frontend only classifies. The GPU's click history
deepens to two entries so a third same-byte click inside the window
classifies as `TripleDown` (and the chain then restarts). Daemon
selects the line *including* its trailing newline — the convention
that makes consecutive triple-click-drag select whole lines.
The wire note: v6 narrowed `SUPPORTED` to `[6]` because the encoding
broke. `TripleDown` is the old cheap kind of bump — an additive
variant on a frontend→instance enum, gated at send (`>= 7`), so
`SUPPORTED` becomes `[6, 7]` on both sides and the compat ladder
restarts on top of the v6 floor.
## Q#M5 — Shift-click selection extension
**Stance: daemon-side, zero wire change.** `mods` already rides every
`Pointer` (carried "for future Shift-click" since v5). In
`dispatch_pointer`, a `Down` with SHIFT extends instead of restarts:
keep the existing anchor when a selection exists, else anchor at the
*pre-click* cursor; then move the cursor to the clicked byte. Drag
after a Shift-Down extends from that inherited anchor unchanged.
Frontend side, a Shift-Down must not enter the double-click chain
(Shift-click twice ≠ word select).
## Q#M6 — minimap click-to-jump
**Stance: GPU-local, consumed before text hit-testing, scrubbable.**
A press inside the minimap band never becomes a `Pointer` event: it
maps pixel y through the same linear line interpolation the painter
uses, centers the viewport on that line, and ships the new viewport
through the existing scroll machinery (`viewport_send_if_origin_
changed`). Holding and moving scrubs continuously — same mapping per
`CursorMoved` while the press started in the band. The viewport is
frontend-owned (S1), so no daemon involvement at all.
## Q#M7 — drag auto-scroll at window edges
**Stance: `ControlFlow::WaitUntil` tick, ~35ms, armed only while
dragging in the edge band.** While `pointer_drag_active` and the
pointer sits within an EDGE_BAND (~24px) of the text area's top or
bottom, each tick scrolls one line toward the pointer and re-runs the
drag hit-test at the *current* pointer position (the mouse may not
move — `CursorMoved` alone would stall the selection). Disarmed the
moment the button releases or the pointer leaves the band; the event
loop returns to `Wait`. No daemon involvement: the scroll is local,
and the re-fired `Drag` rides the existing pointer path.
## Predicted findings (categorical bets)
1. **Click-chain misclassification**: the deepened history interacts
wrongly with some sequence (double, pause, click; or Shift-click
between clicks) and a gesture fires as the wrong kind — surfaces
as an unexpected word/line selection during validation.
2. **Jump-then-stale styling**: a far minimap jump exercises the
viewport-end drift / overscan path harder than wheel scroll does;
one frame of unstyled or stale-styled text flashes after the jump.
3. **Auto-scroll boundary stickiness**: at buffer top/bottom the tick
keeps firing (or the viewport oscillates against the clamp) —
surfaces as jitter or a hot loop at the extremes.
## Session plan
Order: Shift-click (daemon only) → triple-click (v7 + daemon + GPU)
→ minimap jump (GPU) → edge auto-scroll (GPU). Tests per piece;
manual validation: shift-click extend in both directions, triple-click
then drag, double-then-triple chains, minimap jump near top/bottom,
drag-select past both window edges.

View File

@ -90,6 +90,17 @@ const MINIMAP_BG: [f32; 4] = [0.075, 0.075, 0.105, 0.92];
const MINIMAP_DEFAULT_LINE: [f32; 4] = [0.23, 0.23, 0.29, 0.82];
const MINIMAP_THUMB_FILL: [f32; 4] = [0.82, 0.82, 0.92, 0.18];
const MINIMAP_THUMB_BORDER: [f32; 4] = [0.86, 0.86, 0.96, 0.7];
/// Q#M7 — dragging within this many pixels of the text area's top or
/// bottom edge auto-scrolls toward the pointer.
const EDGE_SCROLL_BAND: f32 = 24.0;
/// Q#M7 — one line per tick while edge-scrolling.
const EDGE_SCROLL_TICK: std::time::Duration = std::time::Duration::from_millis(35);
/// Q#M6 (bet #2) — after a far jump (no shaped line reused), hold
/// the redraw this long so the daemon's restyle usually lands before
/// the first visible frame: the styled frame replaces the unstyled
/// flash. Short enough to read as instantaneous when styling never
/// arrives (plain-text buffers).
const JUMP_STYLE_HOLD: std::time::Duration = std::time::Duration::from_millis(25);
const QUAD_SHADER: &str = r"
struct VertexOut {
@builtin(position) pos: vec4<f32>,
@ -409,9 +420,30 @@ 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)>,
/// A press began inside the minimap band (Q#M6): subsequent
/// `CursorMoved` scrubs the viewport instead of dragging a
/// selection, until release. Never sends `Pointer` events —
/// the viewport is frontend-owned.
minimap_scrub_active: bool,
/// Q#M7 — `Some(±1)` while a drag sits in the top/bottom edge
/// band; `about_to_wait` ticks the viewport one line toward the
/// pointer per [`EDGE_SCROLL_TICK`] and re-runs the drag
/// hit-test (the mouse may be stationary — `CursorMoved` alone
/// would stall the selection).
edge_scroll_dir: Option<i64>,
/// When the last edge-scroll tick fired.
edge_scroll_last: Option<std::time::Instant>,
/// Q#M6 (bet #2) — a far jump rebuilt every visible line from
/// spans that can't cover the new region; the redraw is held
/// until restyle arrival (which clears this) or this deadline,
/// whichever is first, so the unstyled frame usually never
/// shows. `about_to_wait` enforces the deadline.
styled_redraw_deadline: Option<std::time::Instant>,
/// 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
@ -433,7 +465,7 @@ struct State {
}
/// pmacs-gpu's own cursor position, mirrored from `CursorByte`.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct OwnCursor {
buffer_id: BufferId,
byte: u64,
@ -472,6 +504,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}");
}
@ -599,9 +640,27 @@ impl ApplicationHandler<AppEvent> for App {
return;
};
state.pointer_pos = Some((position.x, position.y));
if state.minimap_scrub_active {
// Scrubbing (Q#M6): the press began on the
// minimap; motion keeps jumping, even if the
// pointer wanders out of the band.
let vp = state.minimap_jump_to(position.y);
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) =
client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: minimap scrub send_viewport failed: {e}");
}
return;
}
if !state.pointer_drag_active {
return;
}
// Q#M7 — arm/disarm edge auto-scroll from the drag's
// vertical position; `about_to_wait` runs the ticks.
state.edge_scroll_dir =
edge_scroll_direction(position.y as f32, state.config.height);
// Drag coalescing (predicted finding #4): pixel-rate
// motion only ships when the hit byte changes.
let Some(byte) = state.hit_test_source_byte(position.x, position.y) else {
@ -632,10 +691,25 @@ impl ApplicationHandler<AppEvent> for App {
let mods = translate_mods(self.modifiers);
match button_state {
ElementState::Pressed => {
if state.in_minimap_band(x, y) {
// Q#M6 — consumed before text hit-testing;
// never a Pointer event.
state.minimap_scrub_active = true;
let vp = state.minimap_jump_to(y);
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) =
client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: minimap jump send_viewport failed: {e}");
}
return;
}
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();
@ -647,10 +721,16 @@ impl ApplicationHandler<AppEvent> for App {
}
}
ElementState::Released => {
if state.minimap_scrub_active {
state.minimap_scrub_active = false;
return;
}
if !state.pointer_drag_active {
return;
}
state.pointer_drag_active = false;
state.edge_scroll_dir = None;
state.edge_scroll_last = None;
let byte = state
.hit_test_source_byte(x, y)
.or(state.last_pointer_sent_byte);
@ -696,6 +776,84 @@ impl ApplicationHandler<AppEvent> for App {
}
}
/// The deadline pump. Two timed concerns share it, both armed
/// rarely:
///
/// * Q#M7 — the edge auto-scroll tick, while a drag sits in
/// the top/bottom edge band. Each due tick scrolls one line
/// toward the pointer and re-runs the drag hit-test at the
/// *current* pointer position, so the selection keeps
/// growing while the mouse is stationary past the edge.
/// * Q#M6 (bet #2) — the post-jump styled-redraw hold: if the
/// daemon's restyle hasn't landed by the deadline, draw the
/// unstyled frame anyway (responsiveness floor).
///
/// With neither armed the loop stays in plain `Wait`.
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let Some(state) = self.state.as_mut() else {
return;
};
let now = std::time::Instant::now();
let mut next_wake: Option<std::time::Instant> = None;
// Q#M6 — held post-jump frame.
if let Some(deadline) = state.styled_redraw_deadline {
if now >= deadline {
state.styled_redraw_deadline = None;
state.window.request_redraw();
} else {
next_wake = Some(deadline);
}
}
// Q#M7 — edge auto-scroll.
let mut drag_resend: Option<(BufferId, u64)> = None;
if state.pointer_drag_active
&& let Some(dir) = state.edge_scroll_dir
{
let due = state
.edge_scroll_last
.is_none_or(|at| now.duration_since(at) >= EDGE_SCROLL_TICK);
if due {
state.edge_scroll_last = Some(now);
let vp = state.scroll_by_lines(dir);
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: edge-scroll send_viewport failed: {e}");
}
let state = self.state.as_mut().expect("checked above");
if let Some((x, y)) = state.pointer_pos
&& let Some(byte) = state.hit_test_source_byte(x, y)
&& state.last_pointer_sent_byte != Some(byte)
{
state.last_pointer_sent_byte = Some(byte);
state.note_pointer_round_trip();
if let Some(buffer_id) = state.current_buffer_id {
drag_resend = Some((buffer_id, byte));
}
}
}
let last = self
.state
.as_ref()
.and_then(|s| s.edge_scroll_last)
.unwrap_or(now);
let tick_wake = last + EDGE_SCROLL_TICK;
next_wake = Some(next_wake.map_or(tick_wake, |w| w.min(tick_wake)));
}
if let Some((buffer_id, byte)) = drag_resend {
let mods = translate_mods(self.modifiers);
self.send_pointer(buffer_id, byte, PointerKind::Drag, mods);
}
event_loop.set_control_flow(match next_wake {
Some(at) => winit::event_loop::ControlFlow::WaitUntil(at),
None => winit::event_loop::ControlFlow::Wait,
});
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: AppEvent) {
let Some(state) = self.state.as_mut() else {
return;
@ -1079,6 +1237,10 @@ impl State {
pointer_drag_active: false,
last_pointer_sent_byte: None,
last_pointer_down: None,
minimap_scrub_active: false,
edge_scroll_dir: None,
edge_scroll_last: None,
styled_redraw_deadline: None,
hit_map_dirty: false,
line_chunk_cache: Vec::new(),
shaped_top: 0,
@ -1733,17 +1895,29 @@ impl State {
return None;
}
}
self.own_cursor = Some(OwnCursor {
let arrived = OwnCursor {
buffer_id,
byte: byte_pos,
});
};
let moved = self.own_cursor != Some(arrived);
self.own_cursor = Some(arrived);
self.cursor_fresh = self.current_buffer_id == Some(buffer_id);
// Session S1 — keep the caret on screen (Q#S2). When the
// cursor leaves the visible slice (arrows past an edge,
// PageUp/Down), scroll to follow it, re-shape the new
// slice, and re-declare the scoped Viewport so the
// producer ships spans for what's now visible.
if self.scroll_to_cursor() {
//
// Only when the cursor MOVED. The daemon attaches a
// CursorByte to every frame it produces — including
// the frames our own Viewport sends trigger — so an
// unconditional follow snapped the viewport back to
// a stationary cursor on every minimap jump / scrub
// (and on any wheel scroll past the cursor's screen):
// jump → Viewport → frame + re-announced CursorByte →
// snap, in a loop. Scrolling away from a cursor that
// isn't moving is the user's prerogative.
if moved && self.scroll_to_cursor() {
// Pure scroll: retained lines keep their shape
// caches; only newly exposed lines shape.
self.rebuild_lines_reusing_scroll();
@ -1883,6 +2057,24 @@ impl State {
.and_then(|bid| self.viewport_send_if_changed(bid))
}
/// True when the pixel position lies inside the minimap band
/// (Q#M6). Presses here are consumed locally and never become
/// `Pointer` events.
fn in_minimap_band(&self, x: f64, y: f64) -> bool {
minimap_band_contains(x as f32, y as f32, self.config.width, self.config.height)
}
/// Center the viewport on the source line the minimap pixel `y`
/// maps to — the inverse of the painter's linear line→y
/// interpolation. Reuses [`Self::scroll_by_lines`] for the
/// clamp / rebuild / viewport-send plumbing.
fn minimap_jump_to(&mut self, y: f64) -> Option<ViewportSend> {
let target =
minimap_y_to_line(y as f32, self.config.height, self.current_line_starts.len())?;
let centered = target.saturating_sub(estimated_visible_lines(self.config.height) / 2);
self.scroll_by_lines(centered as i64 - self.scroll_top as i64)
}
/// Q#R1 — per-line incremental reshape for a single-line text
/// edit: rebuild ONE `BufferLine` instead of re-shaping the whole
/// visible slice. Returns `false` when the edit needs the full
@ -1997,6 +2189,7 @@ impl State {
.collect();
let mut lines = Vec::with_capacity(ranges.len());
let mut cache = Vec::with_capacity(ranges.len());
let mut any_reused = false;
for (i, &(ls, ce)) in ranges.iter().enumerate() {
let abs = new_top + i;
let reused = abs.checked_sub(old_top).and_then(|j| {
@ -2007,6 +2200,7 @@ impl State {
}
});
if let Some((line, chunks)) = reused {
any_reused = true;
lines.push(line);
cache.push(chunks);
} else {
@ -2022,7 +2216,17 @@ impl State {
.set_scroll(glyphon::cosmic_text::Scroll::default());
self.buffer.shape_until_scroll(&mut self.font_system, false);
self.hit_map_dirty = true;
self.window.request_redraw();
if any_reused || self.line_chunk_cache.is_empty() {
self.styled_redraw_deadline = None;
self.window.request_redraw();
} else {
// Far jump (Q#M6, bet #2): every line rebuilt, and the
// span set covers the *old* viewport — drawing now would
// flash unstyled text. Hold the redraw until the restyle
// lands (`refresh_changed_lines` clears this) or the
// deadline fires in `about_to_wait`.
self.styled_redraw_deadline = Some(std::time::Instant::now() + JUMP_STYLE_HOLD);
}
}
/// Re-shape ONLY lines whose chunk set changed — the incoming
@ -2054,6 +2258,9 @@ impl State {
self.buffer.shape_until_scroll(&mut self.font_system, false);
self.hit_map_dirty = true;
}
// Fresh styling reached the slice — release any held
// post-jump frame (Q#M6, bet #2).
self.styled_redraw_deadline = None;
self.window.request_redraw();
}
@ -2068,19 +2275,38 @@ 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`.
fn classify_pointer_down(&mut self, byte: u64) -> PointerKind {
/// 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
// 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
});
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
}
}
}
@ -2310,6 +2536,8 @@ impl State {
// The pointer hit map rebuilds lazily from the same caches
// (Q#R2) — clicks are rare next to keystrokes/frames.
self.hit_map_dirty = true;
// Full restyle: release any held post-jump frame (Q#M6).
self.styled_redraw_deadline = None;
self.window.request_redraw();
}
@ -2521,7 +2749,11 @@ impl State {
&self.current_line_shapes,
self.config.width,
self.config.height,
0,
// The thumb tracks the live scroll position. (It was
// hardcoded to 0 from the minimap's first session —
// surfaced by Q#M6 validation, where jumping finally
// made the frozen thumb obvious.)
self.scroll_top,
visible_lines,
);
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
@ -2848,6 +3080,50 @@ fn estimated_visible_lines(surface_height: u32) -> usize {
.max(1.0) as usize
}
/// True when `(x, y)` lies inside the minimap band — the painter's
/// geometry (`minimap_left` × the `MINIMAP_TOP..bottom` column),
/// shared by the Q#M6 press hit-test.
fn minimap_band_contains(x: f32, y: f32, surface_width: u32, surface_height: u32) -> bool {
let Some(left) = minimap_left(surface_width) else {
return false;
};
let height = surface_height as f32 - MINIMAP_TOP - MINIMAP_BOTTOM;
height > 0.0
&& x >= left
&& x < surface_width as f32 - MINIMAP_RIGHT
&& y >= MINIMAP_TOP
&& y < MINIMAP_TOP + height
}
/// Q#M7 — which way (if any) a drag at pixel `y` should auto-scroll:
/// `-1` in the band hugging the text area's top, `+1` in the band at
/// the surface bottom, `None` in the interior.
fn edge_scroll_direction(y: f32, surface_height: u32) -> Option<i64> {
if y < TEXT_TOP + EDGE_SCROLL_BAND {
Some(-1)
} else if y > surface_height as f32 - EDGE_SCROLL_BAND {
Some(1)
} else {
None
}
}
/// Map a minimap pixel `y` to a whole-file source line — the inverse
/// of the painter's `y = MINIMAP_TOP + line * height / total`
/// interpolation, clamped into the file. `None` for an empty file or
/// a degenerate surface.
fn minimap_y_to_line(y: f32, surface_height: u32, total_lines: usize) -> Option<usize> {
if total_lines == 0 {
return None;
}
let height = surface_height as f32 - MINIMAP_TOP - MINIMAP_BOTTOM;
if height <= 0.0 {
return None;
}
let frac = ((y - MINIMAP_TOP) / height).clamp(0.0, 1.0);
Some(((frac * total_lines as f32) as usize).min(total_lines - 1))
}
fn minimap_rects(
lines: &[CellStyle],
shapes: &[MinimapLineShape],
@ -4873,6 +5149,50 @@ mod tests {
assert_eq!(chunk_texts(&chunks), vec!["abcd", "X"]);
}
#[test]
fn minimap_band_and_inverse_line_mapping() {
// 800×600 surface: band x = [800-12-48, 800-12) = [740, 788),
// y = [12, 588).
assert!(minimap_band_contains(750.0, 100.0, 800, 600));
assert!(
!minimap_band_contains(739.0, 100.0, 800, 600),
"left of band"
);
assert!(
!minimap_band_contains(788.0, 100.0, 800, 600),
"right of band"
);
assert!(!minimap_band_contains(750.0, 5.0, 800, 600), "above band");
assert!(!minimap_band_contains(750.0, 590.0, 800, 600), "below band");
// Too-narrow surfaces have no minimap at all.
assert!(!minimap_band_contains(100.0, 100.0, 150, 600));
// Inverse mapping: height = 576; 100 lines. Top → line 0,
// bottom → last line, midpoint → ~half.
assert_eq!(minimap_y_to_line(12.0, 600, 100), Some(0));
assert_eq!(minimap_y_to_line(587.9, 600, 100), Some(99));
assert_eq!(minimap_y_to_line(12.0 + 288.0, 600, 100), Some(50));
// Out-of-band y clamps rather than panics (scrubbing wanders).
assert_eq!(minimap_y_to_line(0.0, 600, 100), Some(0));
assert_eq!(minimap_y_to_line(9999.0, 600, 100), Some(99));
assert_eq!(minimap_y_to_line(100.0, 600, 0), None, "empty file");
}
#[test]
fn edge_scroll_direction_bands() {
// 600px surface: up-band y < 16 + 24 = 40; down-band y > 576.
assert_eq!(edge_scroll_direction(10.0, 600), Some(-1));
assert_eq!(edge_scroll_direction(39.9, 600), Some(-1));
assert_eq!(edge_scroll_direction(40.0, 600), None, "interior");
assert_eq!(edge_scroll_direction(300.0, 600), None);
assert_eq!(
edge_scroll_direction(576.0, 600),
None,
"band edge exclusive"
);
assert_eq!(edge_scroll_direction(577.0, 600), Some(1));
}
#[test]
fn minimap_rects_project_line_styles_as_right_side_bands() {
let red = style_with_fg(CellColor::Rgb(255, 0, 0));

View File

@ -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`].

View File

@ -787,23 +787,27 @@ 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
/// 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.
/// `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 +832,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();
@ -851,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();
}
}
}
@ -4441,6 +4460,69 @@ 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};
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.

View File

@ -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).

View File

@ -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 v1v5 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 v1v5 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"
);
}
}