8.1 KiB
pmacs-gpu mouse input — framing pass
Date: 2026-06-10. Follows the Phase B framing
(pmacs-gpu-phase-b-framing.md), which deferred the mouse wire
decision as Q#B5. The optimistic-typing arc is merged (PR #60,
main @ 7fdae76); this is the next capability session.
What exists today (fact-checked)
- Wire:
FrontendEvent::Mouse(MouseEvent)carries aCellCoord(pmacs-protocol/src/message.rs:210-219) — a cell-grid coordinate.PROTOCOL_VERSION = 4(message.rs:970); the DispatchIdle variant set the precedent for gating new variants onnegotiated_protocol_version(daemon.rs:998). - Daemon semantics:
EditorState::dispatch_mouseowns the full gesture state machine — Down activates the window, positions the cursor, begins a selection; Drag grows the region; Up collapses an empty region; a second Down in the same cell within 500ms is a double-click and callsselect_word_at_cursor; ScrollUp/Down move the viewport (src/editor.rs, CUA arc). For a semantic session,apply_semantic_input_eventalready routesFrontendEvent::Mouse → mouse_to_crossterm → dispatch_mouse(daemon.rs:1983-1986) — against the session's placeholder 24×80 cell geometry, which pmacs-gpu does not have. - GPU layout: the shaped buffer holds the visible slice of the
source (
current_text[vstart..vend]) with inline adornment text injected byprojected_rich_chunks(pmacs-gpu/src/main.rs:3202) — so shaped-buffer byte offsets ≠ source bytes whenever inlay hints are visible. Text is drawn atTEXT_LEFT/TEXT_TOPpixel offsets. - Hit testing:
cosmic_text::Buffer::hit(x, y) -> Option<Cursor>exists (cosmic-text 0.18.2,buffer.rs:923) and returns a cursor in the shaped buffer's (line, byte-index-within-line) space — the same line-relative space QB3 taught us to rebase via the slice line table. - Scroll wheel: the GPU owns viewport + scroll state (S1:
scroll_top/ slice reshape / viewport re-declaration), but no winitMouseWheelhandler exists today (fact-check: the window_event catch-all swallows it; scrolling is keyboard-driven). M-2 adds the handler — it composes from existing pieces (scroll_topadjust → reshape →viewport_send_if_changed) and needs no wire.
Q#M1 — the wire shape (resolves Q#B5)
How does a pixel frontend tell the instance where a click landed?
- (α) Reuse
FrontendEvent::Mousewith synthesized cell coords. The GPU would approximate(row, col)from pixels. REJECTED: the daemon's cell math runs against window geometry the GPU doesn't share (placeholder 24×80), inline adornments shift visual columns with no cell-space representation, and the design doc's contract is explicit — the instance never learns a pixel, and there is no hit-test round trip. α is broken-by-design, not merely lossy. - (β1) Local hit-test → byte-position pointer events. The GPU resolves pixels to a source byte offset locally (it owns the layout) and ships gesture-level events; the daemon replays its existing mouse state machine in byte space. Selection semantics (CUA, double-click word select, future triple-click) stay instance-side, exactly like the Key round-trip philosophy.
- (β2) Frontend-computed selection states. The GPU computes cursor+selection itself and ships final states. REJECTED: forks the gesture semantics into a second implementation the TUI doesn't share, and fights the daemon-authoritative selection model the CUA arc just consolidated.
Stance: β1. New wire variant:
FrontendEvent::Pointer {
frontend_id: FrontendId,
buffer_id: BufferId,
/// Source byte offset the frontend hit-tested locally.
byte: u64,
kind: PointerKind, // Down | Drag | Up | DoubleDown
mods: Modifiers,
}
DoubleDown is detected frontend-side (the frontend knows pixel
proximity and its own double-click interval; the daemon's cell-based
double-click detection cannot see pixels). The daemon maps:
Down → activate semantic window + set cursor + begin_selection;
Drag → cursor move (region grows); Up → collapse-if-empty;
DoubleDown → select_word_at_cursor. This reuses the same core
primitives dispatch_mouse calls today — no new selection logic.
Protocol: bump PROTOCOL_VERSION to 5; the GPU sends Pointer only
when hello.protocol_version >= 5; the daemon ignores the variant
for sessions that lack semantic_render.
Q#M2 — projected→source byte mapping
Buffer::hit answers in shaped coordinates: line index + byte
offset within the shaped line, where shaped text = source slice with
adornment text spliced in. Two-step mapping:
- Shaped (line, index) → shaped-buffer byte offset via the shaped line table (the QB3 rebase, but over the projected text).
- Projected byte → source byte via a run map built during
reshape. This is new work, not a current by-product (fact-check:RichChunktoday carries onlytext+color, no source range —main.rs:2269):projected_rich_chunkswalks (source-run | adornment-run) chunks in order, so M-2 extends it to also emitVec<ProjectedRun { projected_start, len, source_start: Option<u64> }>. A hit inside a source run maps linearly; a hit inside an adornment run snaps to the adornment's source anchor.
Stance: build the run map in reshape (it is O(chunks), walked
there anyway) rather than re-deriving adornment offsets at click
time. Clicks land between keystrokes, so the map being rebuilt per
reshape costs nothing new.
Q#M3 — gesture scope for the first session
In: left-click cursor placement, left-drag selection,
double-click word select, wheel scroll (local-only, no wire).
Deferred: triple-click line select (needs a core
select_line_at_cursor), middle-click paste (needs clipboard
session), right-click menu (needs GUI chrome), drag auto-scroll at
window edges (needs a repeat timer; record as a known gap),
minimap click-to-jump (local-only; natural follow-up).
Q#M4 — interaction with optimistic state
A pointer event is a round-trip input: it must (a) mark
cursor_fresh = false until the daemon's CursorByte answers, and
(b) defer behind the optimistic-cursor floor exactly like round-trip
keys do (defer_round_trip_key_if_needed shape) — a click landing
between unconfirmed keystrokes would otherwise race the cursor
confirmation. Deferred pointer events should keep only the latest
Down/Drag (coalescing, like the TUI's frame-boundary mouse
coalescing) rather than queueing every drag sample.
Predicted findings (categorical bets, scored at session close)
- Coordinate-space bug (the QB3 family): some offset in the
pixel→shaped→projected→source chain is wrong on first
implementation — most likely the adornment run snap or the
TEXT_LEFT/TEXT_TOPsubtraction. Surfaces only in manual validation with inlay hints visible. - Window-activation gap (the B1 family): the daemon-side Pointer
handler forgets some part of what
activate_and_positiondoes for grid windows (window focus,goal_colreset), so a click works but a subsequent arrow key moves from the wrong anchor. - Selection-wash latency: the wash arrives a round trip after the drag (Decorations cadence), reading as rubber-band lag. If it surfaces, the fix is frontend-local provisional selection — out of scope, record it.
- Coalescing: drag streams at pixel rate (hundreds of events/sec) flood the socket or the dispatcher. Mitigation in-scope: send Drag only when the hit byte changes.
Session plan
- M-1 (wire + daemon):
PointerKind/Pointervariant, version bump, daemon handler reusing core primitives, unit tests throughhandle_dispatcher_event. Compile-green alone. - M-2 (GPU): winit mouse events → hit test → run map (new, from
projected_rich_chunks) → Pointer sends; drag coalescing on byte change; NEW local wheel-scroll handler; optimistic-floor deferral. Manual validation gate: click placement with inlay hints on the same line, drag selection, double-click, wheel — in both pmacs-gpu and a TUI peer simultaneously.