diff --git a/docs/pmacs-gpu-mouse-deferred-framing.md b/docs/pmacs-gpu-mouse-deferred-framing.md new file mode 100644 index 0000000..509050c --- /dev/null +++ b/docs/pmacs-gpu-mouse-deferred-framing.md @@ -0,0 +1,83 @@ +# Mouse deferred set — framing pass + +Date: 2026-06-12. PR #61 (mouse framing Q#M1–M3) 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. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index ad17dc3..4f63b9c 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -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, @@ -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, - /// `(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, + /// When the last edge-scroll tick fired. + edge_scroll_last: Option, + /// 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, /// 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 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 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 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 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 = 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 { + 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 { + 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 { + 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)); 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 91ca5cb..5016b76 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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. 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" ); } }