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:
commit
b4871ffe7c
|
|
@ -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.
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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