diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 68872c3..f210f39 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -40,10 +40,20 @@ cmd { name = "cursor.paragraph-down", -- Buffer editing ------------------------------------------------------------- -cmd { name = "buffer.delete-backward", description = "Delete the codepoint before the cursor.", - fn = function() ed.backspace() end } -cmd { name = "buffer.delete-forward", description = "Delete the codepoint at the cursor.", - fn = function() ed.delete_forward() end } +-- CUA region semantics: with an active selection, Backspace / Delete +-- consume the region (cursor lands at its start, selection clears). +-- `delete_region` returns false when no region is active, so the +-- single-codepoint behavior is untouched outside selections. +cmd { name = "buffer.delete-backward", + description = "Delete the active region, or the codepoint before the cursor.", + fn = function() + if not ed.delete_region() then ed.backspace() end + end } +cmd { name = "buffer.delete-forward", + description = "Delete the active region, or the codepoint at the cursor.", + fn = function() + if not ed.delete_region() then ed.delete_forward() end + end } cmd { name = "buffer.delete-word-backward", description = "Delete from the cursor back to the start of the previous word.", fn = function() ed.delete_word_backward() end } @@ -79,18 +89,31 @@ cmd { name = "cursor.select-word-left", cmd { name = "cursor.select-word-right", description = "Extend selection by one word right.", fn = function() ensure_anchor(); ed.move_word_right() end } +cmd { name = "cursor.select-paragraph-up", + description = "Extend selection to the previous paragraph break.", + fn = function() ensure_anchor(); ed.move_paragraph_up() end } +cmd { name = "cursor.select-paragraph-down", + description = "Extend selection to the next paragraph break.", + fn = function() ensure_anchor(); ed.move_paragraph_down() end } cmd { name = "cursor.select-line-start", description = "Extend selection to start of line.", fn = function() ensure_anchor(); ed.move_line_start() end } cmd { name = "cursor.select-line-end", description = "Extend selection to end of line.", fn = function() ensure_anchor(); ed.move_line_end() end } -cmd { name = "buffer.newline", description = "Insert a newline at the cursor.", - fn = function() ed.insert_char(10) end } -cmd { name = "buffer.tab", description = "Insert a tab at the cursor.", - fn = function() ed.insert_char(9) end } -cmd { name = "buffer.self-insert", description = "Insert the codepoint argument at the cursor.", - fn = function(codepoint) ed.insert_char(codepoint) end } +-- CUA type-over: inserting with an active selection replaces it +-- (`delete_region` is a no-op without one). The pmacs-gpu frontend +-- relies on this: its optimistic-insert path detects an own-window +-- selection and round-trips the key so these commands run. +cmd { name = "buffer.newline", + description = "Insert a newline at the cursor, replacing the active region.", + fn = function() ed.delete_region(); ed.insert_char(10) end } +cmd { name = "buffer.tab", + description = "Insert a tab at the cursor, replacing the active region.", + fn = function() ed.delete_region(); ed.insert_char(9) end } +cmd { name = "buffer.self-insert", + description = "Insert the codepoint argument at the cursor, replacing the active region.", + fn = function(codepoint) ed.delete_region(); ed.insert_char(codepoint) end } -- History -------------------------------------------------------------------- diff --git a/builtin/keymaps/default.lua b/builtin/keymaps/default.lua index e2b01ad..16adab5 100644 --- a/builtin/keymaps/default.lua +++ b/builtin/keymaps/default.lua @@ -73,7 +73,8 @@ bind("M-d", "buffer.delete-word-forward") -- CUA-style Shift+motion selection. Each Shift+arrow extends a -- selection from the cursor (anchoring at the current position if no -- region is yet active). Ctrl+Shift+Left/Right extend by whole words; --- Shift+Home/End extend to line edges. Plain motion (without Shift) +-- Ctrl+Shift+Up/Down extend by paragraphs; Shift+Home/End extend to +-- line edges. Plain motion (without Shift) -- is unchanged --- it preserves any existing selection rather than -- dropping it (Emacs-flavored default; users who want strict-CUA -- "drop-on-plain-motion" can rebind their motion commands). @@ -85,6 +86,8 @@ bind("S-", "cursor.select-line-start") bind("S-", "cursor.select-line-end") bind("C-S-", "cursor.select-word-left") bind("C-S-", "cursor.select-word-right") +bind("C-S-", "cursor.select-paragraph-up") +bind("C-S-", "cursor.select-paragraph-down") -- Undo / redo ---------------------------------------------------------------- -- diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 8cae97c..9699367 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -233,6 +233,84 @@ local function buffer_text(buf) return buf:slice(0, buf:len()) end +-- didChange coalescing (typing perf) ----------------------------------------- +-- +-- Document sync is full-text, so each `textDocument/didChange` ships +-- the entire buffer. Sending one per keystroke cost three O(file) +-- copies plus an O(file) JSON write to the server pipe *per typed +-- character* — the dominant daemon-side typing cost on large files. +-- The after-edit hook now only bumps the version, marks the cached +-- render families stale (cheap), and records the buffer as dirty; +-- the actual notification ships from the async tick once the buffer +-- has been quiet for DID_CHANGE_QUIET_MS, or unconditionally once +-- the oldest unsent edit is DID_CHANGE_MAX_LAG_MS old (so the server +-- keeps converging during continuous typing). Versions may skip +-- values across a coalesced burst; LSP only requires that they +-- increase. Anything that asks the server about a document flushes +-- it first so no request is answered against stale text. +local DID_CHANGE_QUIET_MS = 75 +local DID_CHANGE_MAX_LAG_MS = 400 + +-- Dirty buffers: key (tostring(buf)) -> { +-- rec = the attachment record the edits belong to, +-- first_ms = monotonic time of the oldest unsent edit, +-- last_ms = monotonic time of the newest unsent edit, +-- } +local pending_did_change = {} + +-- Forward declaration — defined below (needs helpers that follow); +-- `flush_did_change` re-pulls inlay hints after each coalesced send. +local pull_inlay_hints_quiet + +local function flush_did_change(key) + local pending = pending_did_change[key] + if not pending then return end + pending_did_change[key] = nil + local rec = pending.rec + -- The attachment may have been torn down or replaced (server + -- crash -> re-attach) since the edit was recorded; only the live + -- record's server should hear about the buffer. + if attachments[key] ~= rec then return end + local ok, text = pcall(buffer_text, rec.buffer) + if not ok then return end + pcall(pmacs.lsp.did_change, rec.server, rec.uri, rec.version, text) + -- Inlay hints are pull-model: the store's stale flag (set per edit) + -- only clears on a fresh `textDocument/inlayHint` response, and the + -- server never volunteers one. Re-request at flush cadence so + -- hints come back shortly after each pause instead of staying + -- suppressed until the next attach/refresh. The request is + -- supersede-keyed per (server, method, uri), so a burst of flushes + -- cancels its own predecessors rather than piling up. + pcall(pull_inlay_hints_quiet, rec) +end + +local function flush_did_change_for(rec) + if rec and rec.buffer then flush_did_change(tostring(rec.buffer)) end +end + +local function flush_all_did_changes() + for key in pairs(pending_did_change) do + flush_did_change(key) + end +end + +local function flush_due_did_changes() + if next(pending_did_change) == nil then return end + local now = pmacs.editor.monotonic_ms() + for key, pending in pairs(pending_did_change) do + if now - pending.last_ms >= DID_CHANGE_QUIET_MS + or now - pending.first_ms >= DID_CHANGE_MAX_LAG_MS then + flush_did_change(key) + end + end +end + +-- Exposed for tests and for glue that must synchronize the server's +-- document view before an out-of-band operation (e.g. a save hook). +function pmacs.lsp._flush_did_changes() + flush_all_did_changes() +end + local function document_end_position(text) local line, col = 0, 0 for i = 1, #text do @@ -350,9 +428,16 @@ local function server_supports_inlay_hints(sid) return caps.inlayHintProvider ~= nil and caps.inlayHintProvider ~= false end -local function pull_inlay_hints_quiet(rec) +-- Assigns the forward-declared local above (so `flush_did_change` +-- can re-pull); a fresh `local function` here would shadow it. +function pull_inlay_hints_quiet(rec) if not rec or not server_is_initialized(rec.server) then return end if not server_supports_inlay_hints(rec.server) then return end + -- The server must see the current text before being asked to + -- compute positions against it (didChange is debounced). A no-op + -- when called from `flush_did_change` itself (the pending entry is + -- removed before the send), so this cannot recurse. + flush_did_change_for(rec) local end_line, end_col = document_end_position(buffer_text(rec.buffer)) pmacs.async(function() pcall(function() @@ -379,7 +464,12 @@ local function attach_buffer(buf) local key = tostring(buf) local existing = attachments[key] if existing and server_is_live(existing.server) then return existing end - if existing then attachments[key] = nil end + if existing then + attachments[key] = nil + -- Unsent edits targeted the dead attachment; the did_open below + -- carries the full current text, superseding them. + pending_did_change[key] = nil + end local language = active_buffer_language() if not language then return nil end -- Path resolved before spawn so the server's `rootUri` can be @@ -430,7 +520,16 @@ end local function attached_for_active() local buf = pmacs.window.buffer() if not buf then return nil end - return attachments[tostring(buf)] or attach_buffer(buf) + local key = tostring(buf) + local rec = attachments[key] + if rec then + -- Every interactive command resolves its attachment here before + -- issuing requests; flushing now means the server answers those + -- requests against the current text (didChange is debounced). + flush_did_change(key) + return rec + end + return attach_buffer(buf) end -- Hooks -------------------------------------------------------------------- @@ -442,10 +541,21 @@ end) pmacs.hook.add("buffer.after-edit", function() local buf = pmacs.window.buffer() if not buf then return end - local rec = attachments[tostring(buf)] + local key = tostring(buf) + local rec = attachments[key] if not rec then return end rec.version = rec.version + 1 - pcall(pmacs.lsp.did_change, rec.server, rec.uri, rec.version, active_buffer_text()) + -- Stale suppression must stay keystroke-accurate even though the + -- O(file) didChange send below is coalesced: render families + -- anchored to pre-edit positions are hidden from this edit on. + pcall(pmacs.lsp._mark_document_stale, rec.uri) + local now = pmacs.editor.monotonic_ms() + local pending = pending_did_change[key] + if pending and pending.rec == rec then + pending.last_ms = now + else + pending_did_change[key] = { rec = rec, first_ms = now, last_ms = now } + end end) -- Async request surface (T M4.5 async bridge). The Rust manager @@ -658,6 +768,9 @@ end local function repull_for_attachments(sid, request_fn) for _, rec in pairs(attachments) do if rec.server == sid and rec.uri then + -- Server-initiated repulls (diagnostics refresh, semantic + -- tokens refresh) must also see the latest text first. + flush_did_change_for(rec) pcall(request_fn, sid, rec.uri, rec) end end @@ -994,6 +1107,7 @@ if pmacs._async and pmacs._async.tick then pmacs._async.tick = function(...) local ret = _prior_async_tick(...) pcall(handle_server_requests) + pcall(flush_due_did_changes) return ret end end diff --git a/docs/pmacs-gpu-scroll-framing.md b/docs/pmacs-gpu-scroll-framing.md new file mode 100644 index 0000000..2fd3e69 --- /dev/null +++ b/docs/pmacs-gpu-scroll-framing.md @@ -0,0 +1,164 @@ +# pmacs-gpu — viewport-scoped rendering & scroll (framing) + +**Status: framing pass; pre-implementation. Urgent.** Editing a large +file in pmacs-gpu is unusably slow because rendering is O(file) per +keystroke. This milestone makes it O(visible) and adds scrolling. Same +framing discipline as the Phase B framing: Q-decisions committed before +code, fact-checked first. + +## Why this exists (the perf problem, precisely) + +Per keystroke today, for a 240 KB file: + +- **GPU**: `reshape()` rebuilds rich text from the *entire* + `current_text` — `projected_rich_chunks` walks all 240 KB, then + `set_rich_text` creates a `BufferLine` per source line (~25k). It + fires on every edit, and one keystroke produces 2–3 reshapes (the + `CrdtOp`, `StyleSpans`, and `Decorations` frames each trigger one). +- **GPU Viewport**: pmacs-gpu declares `visible: { 0, text_len }` — the + whole file (`main.rs` `BufferSnapshot`/`CrdtOp` arms). So the producer + is asked to style the whole file. +- **Daemon**: on each edit the generation bumps, the `StyleGate` + recomputes, and `scoped_style_spans` runs the tree-sitter highlight + query over the whole bundle (it clips the *result* to the viewport + but runs the query whole-file). + +The GPU's O(file) `set_rich_text` + `projected_rich_chunks` dominate and +run per edit. Editing is hundreds of ms to seconds per character. + +## Goal + +Make the GPU per-edit cost **O(visible lines)**, not O(file), and add +line-based scrolling so the whole file is reachable. Correctness (text, +styling, caret, edits, CRDT convergence) is preserved. + +## Approach: render only the visible slice (stance committed) + +cosmic-text offers a native `Buffer::set_scroll` + `shape_until_scroll` +path, but that only makes *shaping* lazy — `set_rich_text` and +`projected_rich_chunks` would still process the whole rope. So that path +does **not** fix the dominant cost. + +**Stance: feed cosmic-text only the visible byte slice.** The GPU keeps +the whole rope in `current_text` (the CRDT replica is authoritative and +small to hold), but builds chunks and calls `set_rich_text` over +`current_text[vstart..vend]` — the byte range of the visible source +lines. Everything cosmic-text touches is then O(visible): chunk build, +`BufferLine` creation, shaping, layout. Scrolling re-slices and +re-shapes; the buffer always renders from its own top (no cosmic-text +scroll offset needed). + +The cost is **coordinate rebasing**: spans / decorations / caret / +presence arrive in whole-file byte coordinates; the slice starts at +file byte `vstart`, so each offset maps to slice byte `offset - vstart`, +and only the portion intersecting `[vstart, vend)` is rendered. This is +the part to get exactly right — and the QB3 lesson applies (glyph +offsets are line-relative; `line_byte_offsets` is computed on the +slice). + +## Contract inheritance + +Pixel-pure instance preserved: the GPU still sends only a byte-range +`Viewport` and byte-anchored input. The producer already clips +`StyleSpans` / `Decorations` to `vp.visible` (verified), so a scoped +viewport immediately cuts wire volume and GPU span processing with no +producer change required. + +## Forced decisions + +### Q#S1 — scroll unit: line-based + +**Scroll position is a source-line index (`scroll_top`), not a pixel +offset.** The visible slice is `[line_start(scroll_top), +line_start(scroll_top + visible_lines + overscan))`. Line-based scroll +makes the slice always start on a line boundary (cosmic-text splits +`BufferLine`s on `\n`, so a mid-line slice would corrupt the first +line) and matches how `estimated_visible_lines` already works. +Pixel-smooth scroll is a later refinement. + +### Q#S2 — what drives scroll: keep the caret visible + +**The cursor stays on screen.** `scroll_top` is adjusted whenever a +`CursorByte` (own cursor) would fall outside the visible line range: +scroll just enough to bring the cursor's line to the nearest visible +edge (+ a small margin). This is the only scroll trigger session-1 +needs — it makes arrow/PageUp/PageDown navigation work without a +separate scroll command: + +- Arrow up/down past the edge → cursor moves (daemon) → `CursorByte` + → auto-scroll follows. +- `PageUp`/`PageDown` → already forwarded; the daemon moves the cursor + by a page → `CursorByte` → auto-scroll follows. (No GPU-local page + math; the daemon owns cursor motion, Q#B3.) + +Mouse-wheel / scrollbar scroll-without-cursor-move is a later add. + +### Q#S3 — overscan: a small margin + +Slice a few lines beyond the visible region (e.g. visible + 2) so a +1-line scroll doesn't always re-slice, and the bottom partial line +renders. Keep it small — overscan is wasted shaping. + +### Q#S4 — rebasing rule: subtract `vstart`, computed once + +A single `vstart` (file byte of the first visible line) rebases +everything: `slice = current_text[vstart..vend]`; a span/decoration/ +caret at file byte `b` is at slice byte `b - vstart` and is rendered +only if `vstart <= b < vend`; `line_byte_offsets` is computed on +`slice`. One helper clips+rebases a `[start,end)` range to the slice +(returns `None` when disjoint). The caret and the background-wash +builders all route through it. + +### Q#S5 — Viewport declaration: the visible range + +The GPU declares `Viewport { visible: { vstart, vend } }` whenever the +slice changes (scroll or buffer switch), and **re-declares on scroll** +so the producer ships spans for what's on screen. Coalesce: only send +when `[vstart, vend)` actually changed. (Today only `BufferSnapshot` +declares a Viewport; the `CrdtOp` edit arm sends none — so the producer +keeps styling the last-declared range across edits, which is exactly +why the whole-file range is currently sticky.) + +### Q#S6 — daemon whole-file highlight query: out of scope (noted) + +`scoped_style_spans` runs the tree-sitter query over the whole bundle +even for a scoped viewport. After this milestone the GPU is O(visible) +and the wire is scoped, so the daemon query becomes the next bottleneck +on very large files — but it is a *separate* producer optimization +(query-by-range / incremental), deferred to its own task. The GPU +session does not depend on it. + +## Predicted findings — categorical bets + +| # | Bet | Category | +|---|---|---| +| S1 | Off-by-one / mid-line slicing at the bottom edge (partial last line, trailing newline) corrupts the visible text | Boundary-decomposition | +| S2 | Rebasing misses a coordinate space — a span or the caret rendered at `b` instead of `b - vstart`, or vice versa | Coordinate-space (recurrence of QB3) | +| S3 | Scroll re-declares Viewport every frame (not just on change), re-introducing per-frame churn | Cadence | +| S4 | Caret-follow scroll oscillates or lags by a frame when the cursor moves and the slice + caret update on different frames | Temporal-interaction | + +Bet S2 is pre-flagged: the rebasing must be verified against the +cosmic-text source as QB3 taught, not assumed. + +## Session plan + +| Session | Work | Probe | +|---|---|---| +| **S1** | Visible-slice `reshape` (slice + clip/rebase spans, decorations, caret, washes) + `scroll_top` state + caret-follow auto-scroll + scoped Viewport (Q#S1–S5). | Open a 240 KB file: editing is snappy; arrow/PageUp/PageDown navigate the whole file with the caret staying visible; styling/caret/edits correct at any scroll position; TUI stays converged. | +| **S2+** | Mouse-wheel / scrollbar scroll; pixel-smooth scroll; daemon query-by-range (Q#S6). | Per-session. | + +## Process rule (carried forward) + +Touches the core render path — the area that has regressed before. Not +merged until visually confirmed on a large file: editing is fast, the +caret tracks, and styling is correct **after scrolling** (the rebasing +is only exercised once `vstart > 0`). CI-green is necessary, not +sufficient. + +## Deliberately not committed + +- Pixel-smooth (sub-line) scroll — line-based first. +- Mouse-wheel / scrollbar — S2. +- Daemon query-by-range — Q#S6, its own task. +- Horizontal scroll / no-wrap long lines — separate; pmacs-gpu has no + soft wrap yet. diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index b5e1a4e..0e4cf84 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -18,13 +18,14 @@ use std::os::unix::net::UnixStream; use std::path::Path; -use std::sync::{Arc, Mutex}; +use std::sync::mpsc; use std::thread; use pmacs_protocol::{ - AttachRequest, BufferId, ByteRange, FrontendCapabilities, FrontendEvent, FrontendId, Hello, - InstanceMessage, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, TransportError, - is_supported_protocol_version, read_message, write_message, + AttachRequest, BufferId, ByteRange, CrdtOp, FrontendCapabilities, FrontendEvent, FrontendId, + Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, TransportError, is_supported_protocol_version, read_message, + write_message, }; use winit::event_loop::EventLoopProxy; @@ -76,10 +77,10 @@ pub enum AttachEvent { /// Connect, handshake, and spawn the reader thread. /// /// Returns once the handshake has completed and the reader thread is -/// running. The reader thread owns the read half of the stream; the -/// returned [`AttachClient`] retains the write half so the main loop -/// can eventually emit `FrontendEvent`s back to the daemon (session 4 -/// will need this — selection / viewport / edits travel that way). +/// running. The reader thread owns the read half of the stream; a +/// writer thread owns the write half. The returned [`AttachClient`] +/// queues outbound `FrontendEvent`s so the winit UI thread never blocks +/// on daemon socket backpressure. /// /// **Initial window size note** — `AttachRequest::initial_size` is /// nominally a `CellSize` (rows × cols) anchored to the TUI. The @@ -146,12 +147,13 @@ pub fn connect( }; write_message(&mut handshake_stream, &req).map_err(AttachClientError::Handshake)?; - // Split read/write halves for the reader thread + main-thread - // write path. UnixStream clones share the underlying FD with - // independent buffer state — safe to read on one clone while the - // other writes (the FD is full-duplex). + // Split read/write halves for the reader thread + writer thread. + // UnixStream clones share the underlying FD with independent + // buffer state — safe to read on one clone while the other writes + // (the FD is full-duplex). let mut read_stream = stream.try_clone().map_err(AttachClientError::Connect)?; let write_stream = stream; + let (writer_tx, writer_rx) = mpsc::channel::(); // Reader thread. Each iteration: block on read_message, decode, // forward via the event-loop proxy. Exits cleanly on EOF / any @@ -181,22 +183,32 @@ pub fn connect( }) .expect("spawn attach reader thread"); + // Writer thread. Socket writes can block when the daemon falls + // behind; doing them here keeps keyboard input, redraws, and + // message application off that backpressure path. + thread::Builder::new() + .name("pmacs-gpu attach writer".into()) + .spawn(move || { + let mut write_stream = write_stream; + while let Ok(event) = writer_rx.recv() { + if let Err(e) = write_message(&mut write_stream, &event) { + eprintln!("pmacs-gpu: attach writer stopped: {e}"); + return; + } + } + }) + .expect("spawn attach writer thread"); + Ok(AttachClient { - write_stream: Arc::new(Mutex::new(write_stream)), + writer_tx, frontend_id: hello.assigned_frontend_id, }) } -/// Handle the main loop keeps after `connect` returns. Session 4 -/// wires the write side for `FrontendEvent::Viewport` emission; -/// future sessions will add cursor / edit / focus / detach. -/// -/// The write half is wrapped in `Arc>` because, while -/// pmacs-gpu's event loop is single-threaded, a future multi-window -/// shape might emit events from several places concurrently. The -/// lock cost is one mutex per emitted frame — negligible. +/// Handle the main loop keeps after `connect` returns. It queues +/// `FrontendEvent`s for the attach writer thread. pub struct AttachClient { - write_stream: Arc>, + writer_tx: mpsc::Sender, /// Assigned by the daemon in the `Hello` response. Every /// `FrontendEvent` carries this so the daemon can route input back /// to the per-session `SemanticRenderState`. @@ -204,6 +216,11 @@ pub struct AttachClient { } impl AttachClient { + /// Frontend id assigned by the daemon in the initial `Hello`. + pub fn frontend_id(&self) -> FrontendId { + self.frontend_id + } + /// Send a `FrontendEvent::Viewport` to the daemon. The daemon's /// `SemanticRenderState::set_viewport` feeds the spans producer; /// without this call the daemon ships no `StyleSpans` for the @@ -214,18 +231,48 @@ impl AttachClient { visible: ByteRange, generation: u64, ) -> Result<(), TransportError> { - let mut stream = self - .write_stream - .lock() - .expect("attach write-stream mutex poisoned"); - write_message( - &mut *stream, - &FrontendEvent::Viewport { - frontend_id: self.frontend_id, - buffer_id, - visible, - generation, - }, - ) + self.send_event(FrontendEvent::Viewport { + frontend_id: self.frontend_id, + buffer_id, + visible, + generation, + }) + } + + /// Send a `FrontendEvent::Key` to the daemon (session B1). The + /// daemon routes it through `dispatch_key` — the same keymap + + /// command + Lua stack the TUI drives — so cursor motion and (in + /// later sessions) edits are produced entirely instance-side; the + /// resulting `CursorByte` / `CrdtOp` come back over the attach + /// stream. `timestamp_ns` is 0 (no capture clock plumbed yet; the + /// daemon does not depend on it). + pub fn send_key(&self, key: Key, mods: Modifiers) -> Result<(), TransportError> { + self.send_event(FrontendEvent::Key(KeyEvent { + frontend_id: self.frontend_id, + key, + mods, + timestamp_ns: 0, + })) + } + + /// Send a locally-authored CRDT operation to the daemon. The GPU + /// uses this for idle plain-text insertion after applying the same + /// op to its local Loro replica, avoiding a Key round trip on the + /// hot typing path. + pub fn send_crdt_op(&self, buffer_id: BufferId, op: CrdtOp) -> Result<(), TransportError> { + self.send_event(FrontendEvent::CrdtOp { + frontend_id: self.frontend_id, + buffer_id, + op, + }) + } + + fn send_event(&self, event: FrontendEvent) -> Result<(), TransportError> { + self.writer_tx.send(event).map_err(|_| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "attach writer thread stopped", + )) + }) } } diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index b4dd44b..923a542 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -26,22 +26,23 @@ mod attach; use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use glyphon::{ Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, }; +use loro::{ContainerTrait, ExportMode}; use pmacs_protocol::{ - AdornmentContent, AdornmentPlacement, BufferId, ByteRange, Decoration, DecorationKind, - DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, SelectionSnapshot, - StyleSegment, StyleSpan, + AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CrdtOp, Decoration, DecorationKind, + DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, Key as ProtocolKey, Modifiers, + SelectionSnapshot, StyleSegment, StyleSpan, cell::{Color as CellColor, Style as CellStyle}, }; use wgpu::MultisampleState; use wgpu::util::DeviceExt; use winit::application::ApplicationHandler; -use winit::event::{ElementState, KeyEvent, WindowEvent}; +use winit::event::{ElementState, WindowEvent}; use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::keyboard::{Key, NamedKey}; use winit::window::{Window, WindowId}; @@ -65,6 +66,15 @@ const BG: wgpu::Color = wgpu::Color { const TEXT_LEFT: f32 = 16.0; const TEXT_TOP: f32 = 16.0; +/// Caret bar width in px, and its color (bright, near-opaque — drawn +/// over the text so it reads as the active insertion point). Session +/// B1. +const CARET_WIDTH: f32 = 2.0; +const CARET_COLOR: [f32; 4] = [0.90, 0.90, 0.96, 0.90]; +/// Extra source lines shaped beyond the visible window so a 1-line +/// scroll doesn't always re-slice and the bottom partial line renders +/// (Q#S3). Kept small — overscan is wasted shaping. +const SCROLL_OVERSCAN: usize = 2; const TEXT_RIGHT_GAP: f32 = 10.0; const MINIMAP_WIDTH: f32 = 48.0; const MINIMAP_RIGHT: f32 = 12.0; @@ -151,6 +161,7 @@ fn main() { proxy: Some(proxy), state: None, attach_client: None, + modifiers: winit::keyboard::ModifiersState::empty(), }; event_loop .run_app(&mut app) @@ -203,11 +214,16 @@ struct App { proxy: Option>, state: Option, /// Held both for stream lifetime and for the main loop's - /// `send_viewport` write-back path. Session 4 uses this; later - /// sessions will add cursor/edit/focus emissions. + /// `send_viewport` / `send_key` write-back path. attach_client: Option, + /// Latest modifier state from winit (`ModifiersChanged`). winit + /// delivers modifiers separately from key presses, so we track the + /// current set and apply it when a key is sent (session B1). + modifiers: winit::keyboard::ModifiersState, } +type LoroTextDeltaBatches = Arc>>>; + /// All resources owned by one running pmacs-gpu instance. struct State { window: Arc, @@ -225,14 +241,35 @@ struct State { /// What the buffer is currently shaped to. Held so we can detect /// no-op updates and skip the re-shape. current_text: String, - /// Code-shape data derived from `current_text`, used to give the - /// minimap horizontal structure even though `FileStyleSummary` - /// carries only one dominant style per line. + /// Buffer-absolute byte offset for each source line in + /// `current_text`. Updated with text changes and reused by + /// reshape/scroll logic so those paths do not rescan the whole + /// file on every semantic frame. + current_line_starts: Vec, + /// Buffer-absolute Unicode scalar offset for each source line in + /// `current_text`. Loro's text event deltas use Unicode offsets + /// on native builds, so this lets the CRDT hot path convert a + /// retain/delete position to bytes by scanning only one source + /// line instead of the whole prefix. + current_line_char_starts: Vec, + /// Code-shape data used to give the minimap horizontal structure + /// even though `FileStyleSummary` carries only one dominant style + /// per line. Refreshed when a new summary lands, keeping this + /// cache in cadence with the debounced minimap data rather than + /// rebuilding it for every typed byte. current_line_shapes: Vec, /// Local CRDT replica seeded by `BufferSnapshot`. `None` in /// hello-world mode or before the first snapshot arrives in /// attach mode. loro_doc: Option, + /// Pending text diff batches captured from the local Loro replica. + /// `CrdtOp` imports fire the subscription synchronously; the GPU + /// drains these deltas and patches `current_text` incrementally + /// instead of materializing the whole Loro text after each edit. + loro_text_delta_batches: Option, + /// Kept alive for as long as `loro_doc` is active. Dropping it + /// unsubscribes before the next buffer snapshot replaces the doc. + loro_text_subscription: Option, /// Buffer the current rope text + spans interpret. Set when a /// `BufferSnapshot` arrives; used as the routing key for /// `StyleSpans` updates (drop those for other buffers). @@ -280,6 +317,87 @@ struct State { /// these entries rather than from `current_decorations`. Sender /// exclusion at the daemon means our own id never appears here. peer_presences: HashMap, + /// This frontend's own cursor (session B1), from the daemon's + /// `CursorByte`. pmacs-gpu sends `Key` events; the daemon moves the + /// authoritative window cursor and reports it back here (Q#B3), so + /// the caret follows whatever the daemon decided — including motion + /// from commands this frontend never interprets locally. `None` + /// until the first `CursorByte`. + own_cursor: Option, + /// Top visible *source line* (0-based). Scroll is line-based + /// (Q#S1). `reshape` shapes only the lines from here through the + /// visible window; `view_range` records the byte span actually fed + /// to cosmic-text so caret/wash byte offsets can be rebased onto + /// it. + scroll_top: usize, + /// Whole-file byte range `[vstart, vend)` of the slice the + /// cosmic-text `buffer` currently holds (session S1). Everything + /// the buffer renders is in slice coordinates (`file_byte - + /// vstart`); this is the rebasing origin for the caret and the + /// background washes. + view_range: (u64, u64), + /// Last `[vstart, vend)` declared to the daemon via a `Viewport` + /// event. Re-declared only when it changes (scroll, edit that + /// shifts visible bytes, buffer switch) so the producer scopes + /// `StyleSpans` to what's on screen without per-frame churn (Q#S5). + last_viewport_sent: Option<(u64, u64)>, + /// Frontend id assigned by the daemon. Needed for locally-authored + /// optimistic CRDT ops, whose Loro peer id must match the + /// authenticated frontend id the daemon sees on the socket. + local_frontend_id: Option, + /// Daemon-side key dispatcher state. Plain printable chars are + /// optimistically applied only while this is true; when false, + /// keys round-trip so minibuffer and prefix commands keep their + /// daemon-owned semantics. + dispatch_idle: bool, + /// Whether `own_cursor` is still an authoritative position for + /// local optimistic insertion. Round-tripped keys can move the + /// daemon cursor in ways the GPU does not predict, so they mark + /// this false until the next `CursorByte`. + cursor_fresh: bool, + /// Furthest locally-predicted cursor after optimistic inserts that + /// the daemon has not yet confirmed. `CursorByte` frames already + /// in flight can arrive after local typing; accepting one below + /// this floor would rewind subsequent optimistic inserts and + /// scramble their order. + optimistic_cursor_floor: Option, + /// Round-trip keys typed while optimistic inserts are still + /// awaiting confirmation. Sending a backward-moving key before + /// the floor is acknowledged would make its legitimate cursor + /// result indistinguishable from an older in-flight frame. + deferred_round_trip_keys: Vec<(ProtocolKey, Modifiers)>, + /// When the current `optimistic_cursor_floor` was armed. If the + /// daemon never confirms the prediction (op dropped by + /// validation, a peer racing our window cursor), an unbounded + /// floor would wedge deferred round-trip keys forever; after + /// [`FLOOR_CONFIRM_TIMEOUT`] the floor releases, `cursor_fresh` + /// drops, and the next `CursorByte` resynchronizes. + optimistic_floor_set_at: Option, + /// Optimistic local edits not yet known to be reflected in + /// incoming producer frames. Each entry pairs the version scalar + /// of this replica's doc *after* the edit applied (computed by + /// [`loro_version_scalar`], the same per-peer counter sum the + /// daemon stamps into `StyleSpans` / `Decorations` `generation`) + /// with the projection edit itself. On frame arrival, entries at + /// or below the frame's generation are pruned and the frame's + /// byte ranges are translated through the remainder — otherwise a + /// frame computed before an in-flight keystroke repaints the + /// viewport's colors a few bytes left of the text (the typing + /// "color shimmer"). Cleared whenever the cache is rebuilt + /// wholesale (snapshot / full-materialization fallback). + /// + /// Caveat (accepted): scalars from *divergent* replicas are not + /// causally comparable, so a peer edit racing our unconfirmed + /// ops can mis-prune by one frame; the next generation-keyed + /// full resync self-corrects. + unconfirmed_edits: Vec<(u64, TextProjectionEdit)>, +} + +/// pmacs-gpu's own cursor position, mirrored from `CursorByte`. +#[derive(Clone, Copy, Debug)] +struct OwnCursor { + buffer_id: BufferId, + byte: u64, } /// One peer frontend's cursor + selection in a buffer, from @@ -323,6 +441,9 @@ impl ApplicationHandler for App { let proxy = self.proxy.take().expect("proxy taken twice"); match attach::connect(&socket, proxy) { Ok(client) => { + if let Some(state) = self.state.as_mut() { + state.set_frontend_id(client.frontend_id()); + } self.attach_client = Some(client); } Err(e) => { @@ -336,22 +457,90 @@ impl ApplicationHandler for App { } fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { - let Some(state) = self.state.as_mut() else { - return; - }; match event { - WindowEvent::CloseRequested - | WindowEvent::KeyboardInput { - event: - KeyEvent { - logical_key: Key::Named(NamedKey::Escape), - state: ElementState::Pressed, - .. - }, - .. - } => event_loop.exit(), - WindowEvent::Resized(size) => state.resize(size.width.max(1), size.height.max(1)), - WindowEvent::RedrawRequested => state.render(), + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::ModifiersChanged(mods) => self.modifiers = mods.state(), + WindowEvent::KeyboardInput { event: key, .. } => { + if key.state != ElementState::Pressed { + return; + } + // Escape stays a local quit (no daemon round trip). + if matches!(key.logical_key, Key::Named(NamedKey::Escape)) { + event_loop.exit(); + return; + } + // Session B2 forwards cursor motion + plain text editing + // (Char / Backspace / Enter / Delete / Tab). Ctrl/Alt/ + // Meta chords are withheld — they drive commands and + // minibuffer flows the GUI can't render or interact with + // yet (a later session adds GUI minibuffer + chords). + if let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers) + && should_forward_key(pkey, pmods) + && let Some(client) = self.attach_client.as_ref() + { + if let Some(op) = self.state.as_mut().and_then(|state| { + state + .optimistic_crdt_insert(pkey, pmods) + .or_else(|| state.optimistic_crdt_delete(pkey, pmods)) + }) { + if debug_input() { + eprintln!( + "pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B", + op.buffer_id, + op.op.bytes.len() + ); + } + if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) { + eprintln!("pmacs-gpu: send_crdt_op failed: {e}"); + } + // An optimistic Enter near the bottom edge can + // scroll; re-declare the scoped viewport so + // the producer styles the newly visible lines. + if let Some(vp) = op.viewport + && let Err(e) = + client.send_viewport(vp.buffer_id, vp.visible, vp.generation) + { + eprintln!("pmacs-gpu: send Viewport failed: {e}"); + } + return; + } + if let Some(state) = self.state.as_mut() { + if state.defer_round_trip_key_if_needed(pkey, pmods) { + if debug_input() { + eprintln!( + "pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \ + pending optimistic cursor" + ); + } + return; + } + state.mark_cursor_stale_after_round_trip(); + } + if debug_input() { + eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}"); + } + if let Err(e) = client.send_key(pkey, pmods) { + eprintln!("pmacs-gpu: send_key failed: {e}"); + } + } + } + WindowEvent::Resized(size) => { + let vp = self + .state + .as_mut() + .and_then(|state| state.resize(size.width.max(1), size.height.max(1))); + 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: resize send_viewport failed: {e}"); + } + } + WindowEvent::RedrawRequested => { + if let Some(state) = self.state.as_mut() { + state.render(); + } + } _ => {} } } @@ -362,7 +551,16 @@ impl ApplicationHandler for App { }; match event { AppEvent::Attach(AttachEvent::Message(msg)) => { + let debug_apply = debug_apply(); + let apply_start = debug_apply.then(std::time::Instant::now); + let label = debug_apply.then(|| instance_message_label(msg.as_ref())); let follow_up = state.apply_attach_message(*msg); + if let (Some(start), Some(label)) = (apply_start, label) { + eprintln!( + "pmacs-gpu apply: {label}={}us", + std::time::Instant::now().duration_since(start).as_micros() + ); + } // If the message triggered a follow-up Viewport // (currently: every BufferSnapshot does), emit it back // to the daemon. The daemon's `SemanticRenderState` @@ -377,6 +575,18 @@ impl ApplicationHandler for App { { eprintln!("pmacs-gpu: send Viewport failed: {e}"); } + state.release_timed_out_floor(); + let ready_keys = state.take_ready_round_trip_keys(); + if let Some(client) = self.attach_client.as_ref() { + for (key, mods) in ready_keys { + if debug_input() { + eprintln!("pmacs-gpu flush_key: {key:?} mods={mods:?}"); + } + if let Err(e) = client.send_key(key, mods) { + eprintln!("pmacs-gpu: flush send_key failed: {e}"); + } + } + } } AppEvent::Attach(AttachEvent::Disconnected(reason)) => { eprintln!("pmacs-gpu: daemon disconnected ({reason})"); @@ -396,6 +606,79 @@ struct ViewportSend { generation: u64, } +#[derive(Debug)] +struct CrdtOpSend { + buffer_id: BufferId, + op: CrdtOp, + /// A scoped-viewport re-declaration when the optimistic insert + /// scrolled the view (Enter on the bottom visible line). Sent + /// after the op so the producer styles the newly visible range. + viewport: Option, +} + +/// How long an unconfirmed optimistic-cursor prediction may gate +/// `CursorByte` acceptance and defer round-trip keys before the +/// escape hatch releases it. Generous against a busy daemon tick; +/// tiny against a human noticing wedged keys. +const FLOOR_CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); + +/// Byte range an optimistic Backspace/Delete removes at `cursor`, or +/// `None` when it can't be predicted locally: buffer edge (the +/// daemon's behavior is a no-op there anyway), a modifier variant +/// (C-BS word-delete and friends are separate bindings), or a stale +/// mid-codepoint cursor. The range is exactly one codepoint, matching +/// `buffer.delete-backward` / `buffer.delete-forward`'s no-region +/// behavior; region deletes are excluded upstream by the selection +/// gate (they round-trip into `delete_region`). +fn optimistic_delete_range( + text: &str, + cursor: usize, + key: ProtocolKey, + mods: Modifiers, +) -> Option<(usize, usize)> { + if !mods.is_empty() { + return None; + } + if cursor > text.len() || !text.is_char_boundary(cursor) { + return None; + } + match key { + ProtocolKey::Backspace => { + let (start, _) = text[..cursor].char_indices().next_back()?; + Some((start, cursor)) + } + ProtocolKey::Delete => { + let ch = text[cursor..].chars().next()?; + Some((cursor, cursor + ch.len_utf8())) + } + _ => None, + } +} + +/// The literal text `key` inserts when handled optimistically, or +/// `None` for keys that must round-trip through the daemon. +/// +/// `Enter` and `Tab` qualify alongside printable chars because their +/// default bindings (`buffer.newline` / `buffer.tab`) reduce to plain +/// `insert_char(10)` / `insert_char(9)` — byte-identical to a +/// self-insert, so the local application cannot diverge from what the +/// daemon will do with the same op. Two caveats are the caller's job: +/// `optimistic_crdt_insert` round-trips when an own-window selection +/// is active (the daemon commands consume the region first — CUA +/// type-over — which a raw op can't), and modified variants (`S-RET`, +/// `C-TAB`, …) return `None` here: a keymap may bind them to anything. +fn optimistic_insert_text(key: ProtocolKey, mods: Modifiers, chbuf: &mut [u8; 4]) -> Option<&str> { + if !is_plain_text_modifiers(mods) { + return None; + } + match key { + ProtocolKey::Char(ch) if !ch.is_control() => Some(ch.encode_utf8(chbuf)), + ProtocolKey::Enter if mods.is_empty() => Some("\n"), + ProtocolKey::Tab if mods.is_empty() => Some("\t"), + _ => None, + } +} + impl QuadRenderer { fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -540,6 +823,7 @@ impl State { None, ); buffer.shape_until_scroll(&mut font_system, false); + let (current_line_starts, current_line_char_starts) = line_offset_tables(initial_text); Self { window, @@ -555,17 +839,293 @@ impl State { quad_renderer, buffer, current_text: initial_text.to_owned(), + current_line_starts, + current_line_char_starts, current_line_shapes: minimap_line_shapes(initial_text), loro_doc: None, + loro_text_delta_batches: None, + loro_text_subscription: None, current_buffer_id: None, current_spans: Vec::new(), current_decorations: Vec::new(), current_adornments: Vec::new(), current_summary: None, peer_presences: HashMap::new(), + own_cursor: None, + scroll_top: 0, + view_range: (0, 0), + last_viewport_sent: None, + local_frontend_id: None, + dispatch_idle: false, + cursor_fresh: false, + optimistic_cursor_floor: None, + deferred_round_trip_keys: Vec::new(), + optimistic_floor_set_at: None, + unconfirmed_edits: Vec::new(), } } + fn set_frontend_id(&mut self, frontend_id: FrontendId) { + self.local_frontend_id = Some(frontend_id); + if let Some(doc) = self.loro_doc.as_ref() + && let Err(e) = doc.set_peer_id(frontend_id.0) + { + eprintln!("pmacs-gpu: failed to set local Loro peer id: {e:?}"); + } + } + + /// Shared eligibility gates for the optimistic edit paths + /// (insert + delete). `None` ⇒ the key must round-trip: + /// - dispatcher busy (minibuffer/prefix flows own the keys), or + /// the cursor isn't authoritative; + /// - CUA region semantics: an own-window selection means typing + /// replaces and Backspace/Delete consume the region — those + /// semantics live in the daemon's region-aware commands, which + /// a raw `CrdtOp` bypasses. (Our own selection arrives as a + /// `Selection` decoration; peer selections live in + /// `peer_presences` and don't gate.) + /// - bookkeeping: no frontend id / cursor / matching buffer / + /// replica doc, or the peer id can't be set. + fn optimistic_edit_eligible(&self) -> Option<(OwnCursor, u64)> { + if !self.dispatch_idle || !self.cursor_fresh { + return None; + } + if self + .current_decorations + .iter() + .any(|d| d.kind == DecorationKind::Selection) + { + return None; + } + let frontend_id = self.local_frontend_id?; + let own = self.own_cursor?; + if self.current_buffer_id != Some(own.buffer_id) { + return None; + } + let doc = self.loro_doc.as_ref()?; + let peer_id = frontend_id.0; + if doc.peer_id() != peer_id + && let Err(e) = doc.set_peer_id(peer_id) + { + eprintln!("pmacs-gpu: failed to set optimistic Loro peer id: {e:?}"); + return None; + } + Some((own, peer_id)) + } + + fn optimistic_crdt_insert(&mut self, key: ProtocolKey, mods: Modifiers) -> Option { + let mut chbuf = [0u8; 4]; + let insert = optimistic_insert_text(key, mods, &mut chbuf)?; + let (own, peer_id) = self.optimistic_edit_eligible()?; + let cursor = usize::try_from(own.byte).ok()?; + if cursor > self.current_text.len() || !self.current_text.is_char_boundary(cursor) { + return None; + } + let doc = self.loro_doc.as_ref()?; + let delta_batches = self.loro_text_delta_batches.clone()?; + clear_loro_text_delta_batches(&delta_batches); + let before = doc.oplog_vv(); + if let Err(e) = doc + .get_text(LORO_TEXT_CONTAINER) + .insert_utf8(cursor, insert) + { + eprintln!("pmacs-gpu: optimistic insert failed: {e:?}"); + return None; + } + let bytes = doc + .export(ExportMode::updates(&before)) + .expect("export local optimistic Loro update"); + let drained = drain_loro_text_delta_batches(&delta_batches); + let predicted = OwnCursor { + buffer_id: own.buffer_id, + byte: own.byte.saturating_add(insert.len() as u64), + }; + Some(self.finish_optimistic_edit(&drained, predicted, peer_id, bytes)) + } + + /// Optimistic single-codepoint Backspace / Delete. Mirrors the + /// insert path: the daemon's `buffer.delete-backward/-forward` + /// no-region behavior is exactly "delete one codepoint", so the + /// local application cannot diverge; region deletes are excluded + /// by the selection gate (they round-trip into `delete_region`), + /// and modified variants (C-BS word delete, …) round-trip via + /// `optimistic_delete_range` returning `None`. The daemon applies + /// the op through its single-delete CRDT hot path. + fn optimistic_crdt_delete(&mut self, key: ProtocolKey, mods: Modifiers) -> Option { + if !matches!(key, ProtocolKey::Backspace | ProtocolKey::Delete) { + return None; + } + let (own, peer_id) = self.optimistic_edit_eligible()?; + let cursor = usize::try_from(own.byte).ok()?; + let (start, end) = optimistic_delete_range(&self.current_text, cursor, key, mods)?; + let doc = self.loro_doc.as_ref()?; + let delta_batches = self.loro_text_delta_batches.clone()?; + clear_loro_text_delta_batches(&delta_batches); + let before = doc.oplog_vv(); + if let Err(e) = doc + .get_text(LORO_TEXT_CONTAINER) + .delete_utf8(start, end - start) + { + eprintln!("pmacs-gpu: optimistic delete failed: {e:?}"); + return None; + } + let bytes = doc + .export(ExportMode::updates(&before)) + .expect("export local optimistic Loro update"); + let drained = drain_loro_text_delta_batches(&delta_batches); + let predicted = OwnCursor { + buffer_id: own.buffer_id, + byte: start as u64, + }; + Some(self.finish_optimistic_edit(&drained, predicted, peer_id, bytes)) + } + + /// Common tail of the optimistic edit paths: patch the local text + /// from the drained Loro deltas (journaling them for + /// incoming-frame translation), predict the cursor + arm the + /// confirmation floor, follow the caret, and package the wire op. + fn finish_optimistic_edit( + &mut self, + drained: &[Vec], + predicted: OwnCursor, + peer_id: u64, + bytes: Vec, + ) -> CrdtOpSend { + if drained.is_empty() { + let text = self + .loro_doc + .as_ref() + .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); + if let Some(text) = text { + self.set_text(&text); + } + // Cache rebuilt wholesale — there are no translated + // anchors left for frame translation to protect. + self.unconfirmed_edits.clear(); + } else { + match self.apply_loro_text_delta_batches(drained) { + Ok(edits) => { + // Journal this keystroke so producer frames the + // daemon computed before integrating it can be + // translated on arrival (see `unconfirmed_edits`). + // The scalar is read *after* the local edit, so + // any frame stamped at or beyond it includes us. + let scalar = self.loro_doc.as_ref().map_or(0, loro_version_scalar); + self.unconfirmed_edits + .extend(edits.into_iter().map(|e| (scalar, e))); + } + Err(reason) => { + eprintln!( + "pmacs-gpu: optimistic text update failed ({reason}); falling back to \ + full materialization" + ); + let text = self + .loro_doc + .as_ref() + .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); + if let Some(text) = text { + self.set_text(&text); + } + self.unconfirmed_edits.clear(); + } + } + } + self.own_cursor = Some(predicted); + self.optimistic_cursor_floor = Some(predicted); + self.optimistic_floor_set_at = Some(std::time::Instant::now()); + // Follow the caret NOW rather than when the daemon's + // `CursorByte` confirms — an optimistic Enter on the bottom + // visible line (or a Backspace pulling the caret above the + // top) moves it outside the slice, and waiting a round trip + // to scroll reads as a hitch. + let viewport = if self.scroll_to_cursor() { + self.reshape(); + self.viewport_send_if_changed(predicted.buffer_id) + } else { + None + }; + CrdtOpSend { + buffer_id: predicted.buffer_id, + op: CrdtOp { peer_id, bytes }, + viewport, + } + } + + fn mark_cursor_stale_after_round_trip(&mut self) { + self.cursor_fresh = false; + } + + fn apply_loro_text_delta_batches( + &mut self, + delta_batches: &[Vec], + ) -> Result, &'static str> { + let edits = apply_loro_text_delta_batches( + &mut self.current_text, + &mut self.current_line_starts, + &mut self.current_line_char_starts, + delta_batches, + )?; + if edits.is_empty() { + return Ok(edits); + } + self.translate_cached_anchors(&edits); + self.reshape(); + Ok(edits) + } + + fn translate_cached_anchors(&mut self, edits: &[TextProjectionEdit]) { + for edit in edits { + translate_style_spans(&mut self.current_spans, *edit); + translate_decorations(&mut self.current_decorations, *edit); + translate_inline_adornments(&mut self.current_adornments, *edit); + } + } + + /// Drop journal entries already reflected in a producer frame + /// stamped `generation` — see the `unconfirmed_edits` field docs. + fn prune_unconfirmed_edits(&mut self, generation: u64) { + self.unconfirmed_edits + .retain(|(scalar, _)| *scalar > generation); + } + + fn optimistic_floor_timed_out(&self) -> bool { + self.optimistic_floor_set_at + .is_some_and(|armed| armed.elapsed() >= FLOOR_CONFIRM_TIMEOUT) + } + + /// Escape hatch: release a floor the daemon never confirmed so + /// deferred round-trip keys can't wedge forever. Dropping + /// `cursor_fresh` falls the GPU back to round-trip mode until the + /// next `CursorByte` resynchronizes the cursor. + fn release_timed_out_floor(&mut self) { + if self.optimistic_cursor_floor.is_some() && self.optimistic_floor_timed_out() { + eprintln!( + "pmacs-gpu: optimistic cursor unconfirmed after {FLOOR_CONFIRM_TIMEOUT:?}; \ + falling back to round-trip input" + ); + self.optimistic_cursor_floor = None; + self.optimistic_floor_set_at = None; + self.cursor_fresh = false; + } + } + + fn defer_round_trip_key_if_needed(&mut self, key: ProtocolKey, mods: Modifiers) -> bool { + if self.optimistic_cursor_floor.is_none() && self.deferred_round_trip_keys.is_empty() { + return false; + } + self.cursor_fresh = false; + self.deferred_round_trip_keys.push((key, mods)); + true + } + + fn take_ready_round_trip_keys(&mut self) -> Vec<(ProtocolKey, Modifiers)> { + if self.optimistic_cursor_floor.is_some() || self.deferred_round_trip_keys.is_empty() { + return Vec::new(); + } + self.cursor_fresh = false; + std::mem::take(&mut self.deferred_round_trip_keys) + } + /// Replace the rendered text with `text` and request a redraw. /// Returns `false` when `text` is byte-identical to the current /// rendering (avoids the re-shape cost when an unchanged buffer @@ -586,7 +1146,9 @@ impl State { } self.current_text.clear(); self.current_text.push_str(text); - self.current_line_shapes = minimap_line_shapes(text); + let (line_starts, line_char_starts) = line_offset_tables(text); + self.current_line_starts = line_starts; + self.current_line_char_starts = line_char_starts; self.reshape(); true } @@ -601,7 +1163,7 @@ impl State { /// request the daemon scope styling to the new buffer (return a /// Viewport send-back). /// - `CrdtOp` — apply incremental updates to the doc; text - /// re-extracted. + /// patched from Loro's diff event. /// - `StyleSpans` — replace or merge per the M11.4 dirty-segment /// rule; reshape the rich-text rendering. /// - `Decorations` — same M11.4 shape as `StyleSpans` but for the @@ -631,13 +1193,22 @@ impl State { crdt_snapshot, } => { let doc = loro::LoroDoc::new(); + if let Some(frontend_id) = self.local_frontend_id + && let Err(e) = doc.set_peer_id(frontend_id.0) + { + eprintln!("pmacs-gpu: failed to set snapshot Loro peer id: {e:?}"); + } if let Err(e) = doc.import(&crdt_snapshot) { eprintln!("pmacs-gpu: BufferSnapshot import failed: {e:?}"); return None; } let text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); - let text_len = text.len() as u64; + let (text_delta_batches, text_subscription) = subscribe_loro_text(&doc); + self.loro_text_subscription = None; + self.loro_text_delta_batches = None; self.loro_doc = Some(doc); + self.loro_text_delta_batches = Some(text_delta_batches); + self.loro_text_subscription = Some(text_subscription); self.current_buffer_id = Some(buffer_id); // New buffer ⇒ drop any prior styling/decorations; // the next StyleSpans / Decorations frame for this @@ -646,22 +1217,25 @@ impl State { self.current_decorations.clear(); self.current_adornments.clear(); self.current_summary = None; - // Peer cursors are anchored in the prior buffer's - // coordinate space; drop them so a stale offset can't - // paint against the new rope before the next - // PresenceUpdate arrives. + // Peer cursors and our own cursor are anchored in the + // prior buffer's coordinate space; drop them so a stale + // offset can't paint against the new rope before the + // next PresenceUpdate / CursorByte arrives. self.peer_presences.clear(); + self.own_cursor = None; + self.cursor_fresh = false; + self.optimistic_cursor_floor = None; + self.optimistic_floor_set_at = None; + self.deferred_round_trip_keys.clear(); + self.unconfirmed_edits.clear(); + // New buffer ⇒ back to the top, and force a viewport + // re-declaration for the new buffer's scoped range. + self.scroll_top = 0; + self.last_viewport_sent = None; if !self.set_text(&text) { self.reshape(); } - Some(ViewportSend { - buffer_id, - visible: ByteRange { - start: 0, - end: text_len, - }, - generation: 0, - }) + self.viewport_send_if_changed(buffer_id) } InstanceMessage::CrdtOp { buffer_id, op } => { if self.current_buffer_id != Some(buffer_id) { @@ -676,10 +1250,17 @@ impl State { // snapshot will have the ops baked in. return None; }; - if let Err(e) = doc.import(&op.bytes) { - eprintln!("pmacs-gpu: CrdtOp import failed: {e:?}"); - return None; + let delta_batches = self.loro_text_delta_batches.clone(); + if let Some(delta_batches) = delta_batches.as_ref() { + clear_loro_text_delta_batches(delta_batches); } + let import_status = match doc.import(&op.bytes) { + Ok(status) => status, + Err(e) => { + eprintln!("pmacs-gpu: CrdtOp import failed: {e:?}"); + return None; + } + }; // NOTE: `current_spans` / `current_decorations` index // into the *pre-edit* byte positions. The producer's // next render frame (in pmacs core, post-T M11.7 @@ -705,19 +1286,87 @@ impl State { // inlay store stale, and the producer sends one empty // replacement to clear cached virtual text until a // fresh `textDocument/inlayHint` response arrives. - let text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); - self.set_text(&text); - None + let delta_batches = delta_batches + .as_ref() + .map(drain_loro_text_delta_batches) + .unwrap_or_default(); + if delta_batches.is_empty() { + if !import_status.success.is_empty() { + let text = self + .loro_doc + .as_ref() + .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); + if let Some(text) = text { + self.set_text(&text); + } + self.unconfirmed_edits.clear(); + } + } else { + match self.apply_loro_text_delta_batches(&delta_batches) { + Ok(edits) => { + // A daemon-originated edit shifts the text + // under any still-unconfirmed optimistic + // edits. Rebase the journal's anchors so + // frames that include this edit (but not + // ours) translate correctly. Entries are + // inserts (start == old_end) or + // single-codepoint deletes; both rebase by + // position translation, clamped so a range + // can't invert. + for incoming in &edits { + for (_, pending) in &mut self.unconfirmed_edits { + pending.start = + translate_byte_position(pending.start, *incoming); + pending.old_end = + translate_byte_position(pending.old_end, *incoming) + .max(pending.start); + } + } + } + Err(reason) => { + eprintln!( + "pmacs-gpu: incremental CRDT text update failed ({reason}); \ + falling back to full materialization" + ); + let text = self + .loro_doc + .as_ref() + .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); + if let Some(text) = text { + self.set_text(&text); + } + self.unconfirmed_edits.clear(); + } + } + } + // Local typing usually shifts only the viewport's end + // byte while the top visible source line stays fixed. + // The declared range includes overscan, and the daemon's + // generation bump already forces a full style resync, so + // re-declaring on every byte is mostly write amplification. + // Re-declare here only if the viewport origin moved (for + // example because an edit before `scroll_top` shifted the + // top line); scroll/resize/snapshot still send exact ranges. + self.viewport_send_if_origin_changed(buffer_id) } InstanceMessage::StyleSpans { buffer_id, - generation: _, + generation, full, segments, } => { if self.current_buffer_id != Some(buffer_id) { return None; } + // The producer computed this frame against the daemon + // text at `generation` (its CRDT version scalar). Any + // optimistic local inserts the daemon hadn't integrated + // yet shift the frame's byte ranges; translate them so + // the repaint doesn't flash every color after the + // cursor a few bytes left of its glyphs for one frame + // (the typing shimmer). + self.prune_unconfirmed_edits(generation); + let segments = translate_style_segments(segments, &self.unconfirmed_edits); if full { self.replace_style_spans(segments); } else { @@ -728,19 +1377,36 @@ impl State { } InstanceMessage::Decorations { buffer_id, - generation: _, + generation, full, segments, } => { if self.current_buffer_id != Some(buffer_id) { return None; } + // Same staleness translation as the StyleSpans arm. + self.prune_unconfirmed_edits(generation); + let segments = translate_decoration_segments(segments, &self.unconfirmed_edits); + // Only diagnostic decorations affect the *rich text* + // (they override glyph fg in `projected_rich_chunks`); + // background kinds (Selection / CurrentLine / Search) + // are quads rebuilt cheaply in `render()`. A full + // `reshape()` (set_rich_text + shape_until_scroll) on + // every decoration change made cursor motion crawl — + // B1's own `CurrentLine` changes on every up/down move. + // Reshape only when the fg-affecting set changed; else + // just repaint the quads. + let fg_before = fg_decoration_fingerprint(&self.current_decorations); if full { self.replace_decorations(segments); } else { self.merge_decorations(segments); } - self.reshape(); + if fg_before == fg_decoration_fingerprint(&self.current_decorations) { + self.window.request_redraw(); + } else { + self.reshape(); + } None } InstanceMessage::InlineAdornments { buffer_id, items } => { @@ -795,10 +1461,132 @@ impl State { self.window.request_redraw(); None } + // Session B1 — our own cursor. The daemon emits this per + // tick for the replica; the caret + own-window decorations + // follow it. Only meaningful once we send Key events that + // move it. + InstanceMessage::CursorByte { + buffer_id, + byte_pos, + } => { + if debug_input() { + eprintln!( + "pmacs-gpu cursor: buf={buffer_id:?} byte={byte_pos} \ + current={:?} match={}", + self.current_buffer_id, + self.current_buffer_id == Some(buffer_id) + ); + } + if let Some(floor) = self.optimistic_cursor_floor { + // With deletes in the optimistic set the predicted + // cursor is no longer monotonic, so only the EXACT + // predicted byte (or a cursor for another buffer) + // confirms; any other value is an in-flight frame + // from before our unconfirmed edits. The timeout + // hatch accepts daemon truth if confirmation never + // comes (op dropped, peer raced our cursor). + let confirmed = floor.buffer_id != buffer_id || byte_pos == floor.byte; + if confirmed || self.optimistic_floor_timed_out() { + self.optimistic_cursor_floor = None; + self.optimistic_floor_set_at = None; + } else { + if debug_input() { + eprintln!( + "pmacs-gpu cursor: ignored stale in-flight position \ + buf={buffer_id:?} byte={byte_pos} predicted={}", + floor.byte + ); + } + return None; + } + } + self.own_cursor = Some(OwnCursor { + buffer_id, + byte: byte_pos, + }); + 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() { + self.reshape(); + if let Some(vp) = self.viewport_send_if_changed(buffer_id) { + return Some(vp); + } + } + self.window.request_redraw(); + None + } + InstanceMessage::DispatchIdle { idle } => { + self.dispatch_idle = idle; + None + } _ => None, } } + /// A `ViewportSend` for the current `view_range` if it differs from + /// the last one declared, else `None` (Q#S5 coalescing). `generation` + /// is 0 — the producer's full-resync triggers on the visible-range + /// change and on the CRDT generation bump, not this field. + fn viewport_send_if_changed(&mut self, buffer_id: BufferId) -> Option { + if self.last_viewport_sent == Some(self.view_range) { + return None; + } + self.last_viewport_sent = Some(self.view_range); + let (start, end) = self.view_range; + Some(ViewportSend { + buffer_id, + visible: ByteRange { start, end }, + generation: 0, + }) + } + + /// Edit-path variant of [`Self::viewport_send_if_changed`]. For + /// ordinary insertion/deletion inside the visible slice, only the + /// end byte moves; sending that on every `CrdtOp` doubles the + /// frontend-to-daemon write traffic while the producer already has + /// a CRDT generation transition to trigger a full viewport resync. + /// If the start byte moves, the top visible line itself shifted, so + /// the daemon needs a fresh declaration. + fn viewport_send_if_origin_changed(&mut self, buffer_id: BufferId) -> Option { + let Some((last_start, _)) = self.last_viewport_sent else { + return self.viewport_send_if_changed(buffer_id); + }; + if last_start == self.view_range.0 { + return None; + } + self.viewport_send_if_changed(buffer_id) + } + + /// Adjust `scroll_top` so the own cursor's source line is within the + /// visible window (Q#S2). Returns whether `scroll_top` changed (in + /// which case the caller re-shapes + re-declares the viewport). + fn scroll_to_cursor(&mut self) -> bool { + let Some(own) = self.own_cursor else { + return false; + }; + if self.current_buffer_id != Some(own.buffer_id) { + return false; + } + let line_starts = &self.current_line_starts; + let cursor = own.byte.min(self.current_text.len() as u64); + // Cursor's source line = largest i with line_starts[i] <= cursor. + let cursor_line = line_starts + .partition_point(|&s| s <= cursor) + .saturating_sub(1); + let visible = estimated_visible_lines(self.config.height).max(1); + let old = self.scroll_top; + if cursor_line < self.scroll_top { + self.scroll_top = cursor_line; + } else if cursor_line >= self.scroll_top + visible { + self.scroll_top = cursor_line + 1 - visible; + } + self.scroll_top != old + } + fn apply_file_style_summary( &mut self, buffer_id: BufferId, @@ -815,6 +1603,7 @@ impl State { { return; } + self.current_line_shapes = minimap_line_shapes(&self.current_text); self.current_summary = Some(FileStyleSummaryState { generation, lines }); self.window.request_redraw(); } @@ -978,23 +1767,84 @@ impl State { /// this is bounded by visible bytes. A sweep-line refactor with /// active-set pointers is the obvious upgrade if reshape cost /// surfaces in profile data — recorded but not done in session 5. + /// Whole-file byte range `[vstart, vend)` of the source lines that + /// should be shaped: from `scroll_top` through the visible window + /// plus a small overscan (Q#S1/S3). Both ends fall on line + /// boundaries (cosmic-text splits `BufferLine`s on `\n`, so a + /// mid-line slice would corrupt the first/last line). + fn visible_byte_range(&self) -> (u64, u64) { + let line_starts = &self.current_line_starts; + let n = line_starts.len(); + let top = self.scroll_top.min(n.saturating_sub(1)); + let span = estimated_visible_lines(self.config.height).max(1) + SCROLL_OVERSCAN; + let vstart = line_starts[top]; + let bottom = top.saturating_add(span).min(n); + let vend = if bottom < n { + line_starts[bottom] + } else { + self.current_text.len() as u64 + }; + (vstart, vend) + } + fn reshape(&mut self) { + // Session S1 — shape only the visible byte slice. Feeding the + // whole rope to `set_rich_text` (a BufferLine per source line) + // made large-file editing O(file) per keystroke; cosmic-text + // touches only `current_text[vstart..vend]` now. Spans / + // decorations / adornments arrive in whole-file coordinates and + // are clipped + rebased onto the slice (subtract `vstart`). + let (vstart, vend) = self.visible_byte_range(); + self.view_range = (vstart, vend); + let slice = &self.current_text[vstart as usize..vend as usize]; + + let spans: Vec = self + .current_spans + .iter() + .filter_map(|sp| { + clip_rebase_range(sp.range.start, sp.range.end, vstart, vend).map(|(s, e)| { + StyleSpan { + range: ByteRange { start: s, end: e }, + style: sp.style, + } + }) + }) + .collect(); + let decorations: Vec = self + .current_decorations + .iter() + .filter_map(|d| { + clip_rebase_range(d.range.start, d.range.end, vstart, vend).map(|(s, e)| { + Decoration { + range: ByteRange { start: s, end: e }, + kind: d.kind, + } + }) + }) + .collect(); + let adornments: Vec = self + .current_adornments + .iter() + .filter(|a| a.at >= vstart && a.at <= vend) + .map(|a| { + let mut a = a.clone(); + a.at -= vstart; + a + }) + .collect(); + let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono")); - let chunks: Vec<(String, Attrs<'static>)> = projected_rich_chunks( - &self.current_text, - &self.current_spans, - &self.current_decorations, - &self.current_adornments, - ) - .into_iter() - .map(|chunk| { - let mut attrs = default_attrs.clone(); - if let Some(c) = chunk.color { - attrs = attrs.color(c); - } - (chunk.text, attrs) - }) - .collect(); + let chunks: Vec<(String, Attrs<'static>)> = + projected_rich_chunks(slice, &spans, &decorations, &adornments) + .into_iter() + .map(|chunk| { + let mut attrs = default_attrs.clone(); + if let Some(c) = chunk.color { + attrs = attrs.color(c); + } + (chunk.text, attrs) + }) + .collect(); self.buffer.set_rich_text( &mut self.font_system, chunks.iter().map(|(s, a)| (s.as_str(), a.clone())), @@ -1006,7 +1856,7 @@ impl State { self.window.request_redraw(); } - fn resize(&mut self, width: u32, height: u32) { + fn resize(&mut self, width: u32, height: u32) -> Option { self.config.width = width; self.config.height = height; self.surface.configure(&self.device, &self.config); @@ -1017,7 +1867,12 @@ impl State { Some(width as f32), Some(height as f32), ); + // A taller/shorter window changes the visible line count, so the + // slice + scoped viewport change (session S1). + self.reshape(); self.window.request_redraw(); + self.current_buffer_id + .and_then(|bid| self.viewport_send_if_changed(bid)) } #[allow(clippy::too_many_lines)] // linear per-frame GPU sequence + optional timing. @@ -1049,6 +1904,16 @@ impl State { usage: wgpu::BufferUsages::VERTEX, }) }); + let caret_vertices = self.caret_vertex_bytes(); + let caret_vertex_count = (caret_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; + let caret_buffer = (!caret_vertices.is_empty()).then(|| { + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("pmacs-gpu caret"), + contents: &caret_vertices, + usage: wgpu::BufferUsages::VERTEX, + }) + }); let after_bg = debug_frame().then(std::time::Instant::now); let minimap_vertices = self.minimap_vertex_bytes(); let minimap_vertex_count = (minimap_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; @@ -1122,6 +1987,12 @@ impl State { self.text_renderer .render(&self.atlas, &self.viewport, &mut pass) .expect("text_renderer render"); + // Caret over the text so the insertion point reads on top + // of the glyph it sits before (session B1). + if let Some(vertex_buffer) = caret_buffer.as_ref() { + self.quad_renderer + .render(&mut pass, vertex_buffer, caret_vertex_count); + } if let Some(vertex_buffer) = minimap_buffer.as_ref() { self.quad_renderer .render(&mut pass, vertex_buffer, minimap_vertex_count); @@ -1180,58 +2051,156 @@ impl State { rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } - /// Vertex bytes for quad-pipeline background rectangles. Session - /// 9.3 sources `CurrentLine` / `Selection` washes from peer - /// presence (the editing frontend's cursor + selection) rather - /// than from `current_decorations`: this is a read-only mirror, so - /// its own per-window `Selection` / `CurrentLine` decorations are - /// inert (cursor pinned at 0, no selection). See finding QB1 in - /// `docs/pmacs-gpu-quad-backgrounds-framing.md`. + /// Vertex bytes for quad-pipeline background washes (drawn *under* + /// the text). Two sources, both `Selection` / `CurrentLine`: this + /// frontend's *own* window decorations from `current_decorations` + /// (live again since session B1 reactivated the own cursor — Q#B4; + /// QB1 had suppressed them while the mirror was read-only), and + /// *peer* presence from `PresenceUpdate` (session 9.3). Both reuse + /// the same `\n`-line offset table to rebase cosmic-text's + /// line-relative glyph offsets (QB3). The caret is separate (drawn + /// *over* text) — see [`Self::caret_vertex_bytes`]. fn decoration_background_vertex_bytes(&self) -> Vec { - let rects = self.peer_background_rects(); - rects_to_vertex_bytes(&rects, self.config.width, self.config.height) - } - - /// Background rectangles for every peer's cursor line + selection - /// in the current buffer. `CurrentLine` covers the source line - /// holding the peer cursor; `Selection` covers the peer's selected - /// byte range. Both map byte ranges to per-visual-line glyph - /// extents via `peer_glyph_extent_rects`. Single-peer mirrors reuse - /// the `Selection` / `CurrentLine` colors so the visual reads as - /// "my editing, mirrored"; per-peer distinct colors are deferred. - fn peer_background_rects(&self) -> Vec { let Some(buffer_id) = self.current_buffer_id else { return Vec::new(); }; - let text_len = self.current_text.len() as u64; - // Buffer-absolute byte offset of each `\n`-delimited line, - // indexed by `LayoutRun::line_i`. `LayoutGlyph::{start,end}` are - // offsets within the *original line*, not the whole buffer, so - // every byte range below must be rebased per line before it can - // be matched against glyph offsets. - let line_offsets = line_byte_offsets(&self.current_text); + let (vstart, vend) = self.view_range; + if vend <= vstart { + return Vec::new(); + } + // Glyph offsets are relative to the *slice* the buffer holds + // (session S1), so the line table is computed on the slice and + // every whole-file byte range is clip-rebased onto it. + let slice = &self.current_text[vstart as usize..vend as usize]; + let line_offsets = line_byte_offsets(slice); let mut rects = Vec::new(); + self.collect_own_decoration_rects(&mut rects, &line_offsets, vstart, vend); + self.collect_peer_rects(buffer_id, &line_offsets, vstart, vend, &mut rects); + rects_to_vertex_bytes(&rects, self.config.width, self.config.height) + } + + /// Own-window `Selection` washes from `current_decorations`. The + /// caret already marks the own cursor, so the own *`CurrentLine`* + /// wash is deliberately NOT rendered — a whole-line highlight on + /// every cursor line reads as a persistent selection, which is not + /// wanted as default editor behavior (revising Q#B4: the caret is + /// the own-cursor indicator; the line wash isn't). Peer presence + /// still shows other frontends' lines via `collect_peer_rects`. + fn collect_own_decoration_rects( + &self, + rects: &mut Vec, + line_offsets: &[u64], + vstart: u64, + vend: u64, + ) { + for d in &self.current_decorations { + if d.kind == DecorationKind::CurrentLine { + continue; + } + if let Some(color) = decoration_kind_to_bg_color(d.kind) + && let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) + { + self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); + } + } + } + + /// Peer cursor-line + selection washes from `PresenceUpdate` + /// (session 9.3). Single-peer mirrors reuse the `Selection` / + /// `CurrentLine` colors; per-peer distinct colors are deferred. + fn collect_peer_rects( + &self, + buffer_id: BufferId, + line_offsets: &[u64], + vstart: u64, + vend: u64, + rects: &mut Vec, + ) { + let text_len = self.current_text.len() as u64; for presence in self.peer_presences.values() { if presence.buffer_id != buffer_id { continue; } - // CurrentLine: the source line containing the peer cursor. if let Some(color) = decoration_kind_to_bg_color(DecorationKind::CurrentLine) { let (lo, hi) = source_line_range(&self.current_text, presence.cursor); - self.push_glyph_extent_rects(&mut rects, &line_offsets, lo, hi, color); + if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) { + self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); + } } - // Selection: the peer's selected byte range, normalized. if let Some(sel) = presence.selection && let Some(color) = decoration_kind_to_bg_color(DecorationKind::Selection) { let lo = sel.anchor.min(sel.active).min(text_len); let hi = sel.anchor.max(sel.active).min(text_len); - if hi > lo { - self.push_glyph_extent_rects(&mut rects, &line_offsets, lo, hi, color); + if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) { + self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); } } } - rects + } + + /// Vertex bytes for the caret quad, drawn *over* the text (B1). + /// Empty when no own cursor is known, it's in another buffer, or it + /// is scrolled out of the visible slice. + fn caret_vertex_bytes(&self) -> Vec { + let (vstart, vend) = self.view_range; + if vend <= vstart { + return Vec::new(); + } + let slice = &self.current_text[vstart as usize..vend as usize]; + let line_offsets = line_byte_offsets(slice); + let Some(rect) = self.caret_rect(slice, &line_offsets, vstart, vend) else { + return Vec::new(); + }; + rects_to_vertex_bytes(&[rect], self.config.width, self.config.height) + } + + /// The caret rectangle for the own cursor, in slice coordinates: a + /// thin bar at the left edge of the glyph the cursor sits before (or + /// the right edge of the last glyph at line end). `None` when the + /// cursor is outside the visible slice. Byte→glyph mapping rebases + /// per line (QB3); the cursor is rebased onto the slice first (S1). + fn caret_rect( + &self, + slice: &str, + line_offsets: &[u64], + vstart: u64, + vend: u64, + ) -> Option { + let own = self.own_cursor?; + if self.current_buffer_id != Some(own.buffer_id) { + return None; + } + let cursor = own.byte; + if cursor < vstart || cursor > vend { + return None; // scrolled off-screen + } + let slice_cursor = cursor - vstart; + let (line_lo, _) = source_line_range(slice, slice_cursor); + for run in self.buffer.layout_runs() { + if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo { + continue; + } + let line_base = line_lo; + let mut x = TEXT_LEFT; + for glyph in run.glyphs { + if line_base + glyph.start as u64 >= slice_cursor { + x = TEXT_LEFT + glyph.x; + break; + } + // Cursor is past this glyph; track its right edge so a + // cursor at line end lands after the final glyph. + x = TEXT_LEFT + glyph.x + glyph.w; + } + return Some(MinimapRect { + x, + y: TEXT_TOP + run.line_top, + w: CARET_WIDTH, + h: run.line_height, + color: CARET_COLOR, + }); + } + None } /// Push one rect per visual line whose glyphs overlap the @@ -1575,17 +2544,604 @@ fn debug_frame() -> bool { *FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_FRAME").is_some()) } +/// One-shot env flag: `PMACS_GPU_DEBUG_APPLY=1` logs how long the +/// main thread spends applying each inbound daemon message. This +/// separates CRDT text patching, style replacement, and cursor updates +/// from the later `render()` timings. +fn debug_apply() -> bool { + static FLAG: std::sync::OnceLock = std::sync::OnceLock::new(); + *FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_APPLY").is_some()) +} + +fn instance_message_label(msg: &InstanceMessage) -> &'static str { + match msg { + InstanceMessage::CellDelta { .. } => "CellDelta", + InstanceMessage::Cursor(_) => "Cursor", + InstanceMessage::ModeLine(_) => "ModeLine", + InstanceMessage::Signal(_) => "Signal", + InstanceMessage::Goodbye(_) => "Goodbye", + InstanceMessage::CrdtOp { .. } => "CrdtOp", + InstanceMessage::PresenceUpdate { .. } => "PresenceUpdate", + InstanceMessage::BufferSnapshot { .. } => "BufferSnapshot", + InstanceMessage::CursorByte { .. } => "CursorByte", + InstanceMessage::StyleSpans { .. } => "StyleSpans", + InstanceMessage::Decorations { .. } => "Decorations", + InstanceMessage::InlineAdornments { .. } => "InlineAdornments", + InstanceMessage::FileStyleSummary { .. } => "FileStyleSummary", + InstanceMessage::BlockAdornments { .. } => "BlockAdornments", + InstanceMessage::FoldState { .. } => "FoldState", + InstanceMessage::ResourceOffer { .. } => "ResourceOffer", + InstanceMessage::DispatchIdle { .. } => "DispatchIdle", + } +} + +/// One-shot env flag: `PMACS_GPU_DEBUG_INPUT=1` logs the input path — +/// keys sent and `CursorByte` received (with the buffer it targets vs +/// the buffer being displayed). The buffer comparison is the B1 +/// diagnostic: if `CursorByte` targets a different buffer than +/// `current`, the caret won't track (the displayed/edited buffers are +/// out of sync). +fn debug_input() -> bool { + static FLAG: std::sync::OnceLock = std::sync::OnceLock::new(); + *FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_INPUT").is_some()) +} + +/// Translate a winit logical key + current modifier state into a +/// protocol `(Key, Modifiers)`. Returns `None` for keys the protocol +/// has no representation for (the daemon ignores `Key::Unknown`, so +/// there's no value in forwarding them). `translate_key` covers the +/// full editing set; session B1 gates the send on [`is_motion_key`]. +fn translate_key( + logical: &Key, + mods: winit::keyboard::ModifiersState, +) -> Option<(ProtocolKey, Modifiers)> { + let mut bits = 0u8; + if mods.shift_key() { + bits |= Modifiers::SHIFT.bits(); + } + if mods.control_key() { + bits |= Modifiers::CTRL.bits(); + } + if mods.alt_key() { + bits |= Modifiers::ALT.bits(); + } + if mods.super_key() { + bits |= Modifiers::META.bits(); + } + let pmods = Modifiers::from_bits_truncate(bits); + + let pkey = match logical { + Key::Named(named) => match named { + NamedKey::ArrowLeft => ProtocolKey::Left, + NamedKey::ArrowRight => ProtocolKey::Right, + NamedKey::ArrowUp => ProtocolKey::Up, + NamedKey::ArrowDown => ProtocolKey::Down, + NamedKey::Home => ProtocolKey::Home, + NamedKey::End => ProtocolKey::End, + NamedKey::PageUp => ProtocolKey::PageUp, + NamedKey::PageDown => ProtocolKey::PageDown, + NamedKey::Backspace => ProtocolKey::Backspace, + NamedKey::Enter => ProtocolKey::Enter, + NamedKey::Delete => ProtocolKey::Delete, + NamedKey::Insert => ProtocolKey::Insert, + NamedKey::Tab => ProtocolKey::Tab, + NamedKey::Space => ProtocolKey::Char(' '), + _ => return None, + }, + Key::Character(s) => ProtocolKey::Char(s.chars().next()?), + _ => return None, + }; + Some((pkey, pmods)) +} + +/// Cursor-motion keys — forwarded with any modifier set (e.g. `C-Left` +/// is word-motion, `S-Down` extends a selection; the daemon's keymap +/// decides). +fn is_motion_key(key: ProtocolKey) -> bool { + matches!( + key, + ProtocolKey::Left + | ProtocolKey::Right + | ProtocolKey::Up + | ProtocolKey::Down + | ProtocolKey::Home + | ProtocolKey::End + | ProtocolKey::PageUp + | ProtocolKey::PageDown + ) +} + +/// Whether to forward a translated key to the daemon (session B2). +/// Motion keys go through with any modifiers (C- is word +/// motion). Deletion keys do too: C-BS / C-DEL / M-BS are word-level +/// deletes in the default keymap — the same editing-command family as +/// chorded motion, and an unbound chord is a harmless no-op at the +/// daemon keymap. (Chorded deletes never apply optimistically: +/// `optimistic_delete_range` requires empty modifiers, so they always +/// round-trip into their bound commands.) The remaining text keys +/// (`Char` / `Enter` / `Tab`) go through only *without* a +/// Ctrl/Alt/Meta chord modifier: a bare key edits text, but those +/// chords drive commands and minibuffer flows the GUI can't render or +/// interact with yet (deferred to a later session). Shift is not a +/// chord modifier — `Shift`+a already arrives as `Char('A')`. +fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { + if is_motion_key(key) { + return true; + } + if matches!(key, ProtocolKey::Backspace | ProtocolKey::Delete) { + return true; + } + if !is_plain_text_modifiers(mods) { + return false; + } + matches!( + key, + ProtocolKey::Char(_) | ProtocolKey::Enter | ProtocolKey::Tab + ) +} + +fn is_plain_text_modifiers(mods: Modifiers) -> bool { + !mods.contains(Modifiers::CTRL) + && !mods.contains(Modifiers::ALT) + && !mods.contains(Modifiers::META) + && !mods.contains(Modifiers::HYPER) +} + +/// Clip a whole-file byte range `[start, end)` to the visible slice +/// `[vstart, vend)` and rebase it into slice coordinates (subtract +/// `vstart`). Returns `None` when the range is disjoint from the slice. +/// The single rebasing primitive for session S1 — caret and washes +/// route through it (Q#S4). +fn clip_rebase_range(start: u64, end: u64, vstart: u64, vend: u64) -> Option<(u64, u64)> { + let s = start.max(vstart); + let e = end.min(vend); + if e <= s { + return None; + } + Some((s - vstart, e - vstart)) +} + +/// Sum of the doc's per-peer version-vector counters — the **same +/// formula** as the daemon's `CrdtState::version_scalar`, which is +/// what the producer stamps into `StyleSpans` / `Decorations` +/// `generation`. The sum is integration-order independent, so once +/// both replicas hold the same set of ops the scalars are equal; +/// that is what makes frame generations comparable against locally +/// computed values in `unconfirmed_edits`. +fn loro_version_scalar(doc: &loro::LoroDoc) -> u64 { + doc.oplog_vv() + .values() + .map(|counter| u64::try_from(*counter).unwrap_or(0)) + .sum() +} + +/// Translate one incoming `StyleSpans` frame's segments through the +/// optimistic edits the daemon had not yet integrated when it +/// computed the frame. Ranges that a (defensive) delete fully +/// removes drop out. +fn translate_style_segments( + segments: Vec, + edits: &[(u64, TextProjectionEdit)], +) -> Vec { + if edits.is_empty() { + return segments; + } + segments + .into_iter() + .filter_map(|seg| { + let mut range = seg.range; + let mut spans = seg.spans; + for (_, edit) in edits { + range = translate_byte_range(range, *edit)?; + spans = spans + .into_iter() + .filter_map(|mut sp| { + sp.range = translate_byte_range(sp.range, *edit)?; + Some(sp) + }) + .collect(); + } + Some(StyleSegment { range, spans }) + }) + .collect() +} + +/// `Decorations` twin of [`translate_style_segments`]. +fn translate_decoration_segments( + segments: Vec, + edits: &[(u64, TextProjectionEdit)], +) -> Vec { + if edits.is_empty() { + return segments; + } + segments + .into_iter() + .filter_map(|seg| { + let mut range = seg.range; + let mut decorations = seg.decorations; + for (_, edit) in edits { + range = translate_byte_range(range, *edit)?; + decorations = decorations + .into_iter() + .filter_map(|mut d| { + d.range = translate_byte_range(d.range, *edit)?; + Some(d) + }) + .collect(); + } + Some(DecorationSegment { range, decorations }) + }) + .collect() +} + +fn subscribe_loro_text(doc: &loro::LoroDoc) -> (LoroTextDeltaBatches, loro::Subscription) { + let text = doc.get_text(LORO_TEXT_CONTAINER); + let delta_batches = Arc::new(Mutex::new(Vec::>::new())); + let captured_batches = Arc::clone(&delta_batches); + let subscription = doc.subscribe( + &text.id(), + Arc::new(move |event| { + let mut guard = captured_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for event in event.events { + if let Some(delta) = event.diff.as_text() + && !delta.is_empty() + { + guard.push(delta.clone()); + } + } + }), + ); + (delta_batches, subscription) +} + +fn clear_loro_text_delta_batches(delta_batches: &LoroTextDeltaBatches) { + delta_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); +} + +fn drain_loro_text_delta_batches( + delta_batches: &LoroTextDeltaBatches, +) -> Vec> { + let mut guard = delta_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + std::mem::take(&mut *guard) +} + +/// Largest char-boundary `<= index` (stable equivalent of the unstable +/// `str::floor_char_boundary`). Used to snap externally-supplied byte +/// offsets to valid slice points so a stale, mid-codepoint offset can't +/// panic a `text[..]` slice. +fn floor_char_boundary(text: &str, index: usize) -> usize { + if index >= text.len() { + return text.len(); + } + let mut i = index; + while i > 0 && !text.is_char_boundary(i) { + i -= 1; + } + i +} + /// Buffer-absolute byte offset of the start of each `\n`-delimited /// line (index 0 = byte 0). Indexed by cosmic-text's /// `LayoutRun::line_i` to rebase line-relative glyph offsets. fn line_byte_offsets(text: &str) -> Vec { + line_offset_tables(text).0 +} + +fn line_offset_tables(text: &str) -> (Vec, Vec) { let mut starts = vec![0u64]; - for (i, b) in text.bytes().enumerate() { - if b == b'\n' { - starts.push(i as u64 + 1); + let mut char_starts = vec![0u64]; + let mut chars_seen = 0u64; + for (byte, ch) in text.char_indices() { + chars_seen += 1; + if ch == '\n' { + starts.push(byte as u64 + 1); + char_starts.push(chars_seen); } } - starts + (starts, char_starts) +} + +fn byte_offset_for_char_offset( + text: &str, + line_starts: &[u64], + line_char_starts: &[u64], + char_offset: usize, +) -> Option { + if line_starts.len() != line_char_starts.len() { + return None; + } + let line = line_char_starts + .partition_point(|&start| start <= char_offset as u64) + .saturating_sub(1); + let byte_start = *line_starts.get(line)? as usize; + let char_start = *line_char_starts.get(line)? as usize; + let mut byte = byte_start; + for _ in 0..char_offset.checked_sub(char_start)? { + let ch = text.get(byte..)?.chars().next()?; + byte += ch.len_utf8(); + } + Some(byte) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TextProjectionEdit { + start: u64, + old_end: u64, + inserted_len: u64, +} + +fn apply_loro_text_delta_batches( + text: &mut String, + line_starts: &mut Vec, + line_char_starts: &mut Vec, + delta_batches: &[Vec], +) -> Result, &'static str> { + let mut edits = Vec::new(); + for delta in delta_batches { + apply_loro_text_delta_batch(text, line_starts, line_char_starts, delta, &mut edits)?; + } + Ok(edits) +} + +fn apply_loro_text_delta_batch( + text: &mut String, + line_starts: &mut Vec, + line_char_starts: &mut Vec, + delta: &[loro::TextDelta], + edits: &mut Vec, +) -> Result<(), &'static str> { + let mut cursor_char = 0usize; + for op in delta { + match op { + loro::TextDelta::Retain { retain, .. } => { + cursor_char = cursor_char + .checked_add(*retain) + .ok_or("retain offset overflow")?; + } + loro::TextDelta::Insert { insert, .. } => { + if insert.is_empty() { + continue; + } + let start_byte = + byte_offset_for_char_offset(text, line_starts, line_char_starts, cursor_char) + .ok_or("insert offset outside current text")?; + replace_text_range_with_line_updates( + text, + line_starts, + line_char_starts, + start_byte, + start_byte, + cursor_char, + cursor_char, + insert, + )?; + edits.push(TextProjectionEdit { + start: start_byte as u64, + old_end: start_byte as u64, + inserted_len: insert.len() as u64, + }); + cursor_char = cursor_char + .checked_add(insert.chars().count()) + .ok_or("insert offset overflow")?; + } + loro::TextDelta::Delete { delete } => { + if *delete == 0 { + continue; + } + let start_char = cursor_char; + let end_char = cursor_char + .checked_add(*delete) + .ok_or("delete offset overflow")?; + let start_byte = + byte_offset_for_char_offset(text, line_starts, line_char_starts, start_char) + .ok_or("delete start outside current text")?; + let end_byte = + byte_offset_for_char_offset(text, line_starts, line_char_starts, end_char) + .ok_or("delete end outside current text")?; + replace_text_range_with_line_updates( + text, + line_starts, + line_char_starts, + start_byte, + end_byte, + start_char, + end_char, + "", + )?; + edits.push(TextProjectionEdit { + start: start_byte as u64, + old_end: end_byte as u64, + inserted_len: 0, + }); + } + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn replace_text_range_with_line_updates( + text: &mut String, + line_starts: &mut Vec, + line_char_starts: &mut Vec, + start_byte: usize, + end_byte: usize, + start_char: usize, + end_char: usize, + insert: &str, +) -> Result<(), &'static str> { + if line_starts.len() != line_char_starts.len() { + return Err("line offset tables have different lengths"); + } + if start_byte > end_byte || end_byte > text.len() { + return Err("replacement byte range is outside current text"); + } + if start_char > end_char { + return Err("replacement char range is inverted"); + } + if !text.is_char_boundary(start_byte) || !text.is_char_boundary(end_byte) { + return Err("replacement byte range is not on char boundaries"); + } + + let start_line = line_starts + .partition_point(|&start| start <= start_byte as u64) + .saturating_sub(1); + let remove_start = start_line + 1; + let remove_end = line_starts.partition_point(|&start| start <= end_byte as u64); + let (inserted_line_starts, inserted_line_char_starts) = + inserted_line_offsets(insert, start_byte, start_char); + let inserted_line_count = inserted_line_starts.len(); + let byte_delta = signed_usize_delta(insert.len(), end_byte - start_byte)?; + let char_delta = signed_usize_delta(insert.chars().count(), end_char - start_char)?; + + text.replace_range(start_byte..end_byte, insert); + line_starts.splice(remove_start..remove_end, inserted_line_starts); + line_char_starts.splice(remove_start..remove_end, inserted_line_char_starts); + let suffix_start = remove_start + inserted_line_count; + for start in line_starts.iter_mut().skip(suffix_start) { + shift_u64(start, byte_delta); + } + for start in line_char_starts.iter_mut().skip(suffix_start) { + shift_u64(start, char_delta); + } + Ok(()) +} + +fn inserted_line_offsets( + insert: &str, + start_byte: usize, + start_char: usize, +) -> (Vec, Vec) { + let mut line_starts = Vec::new(); + let mut line_char_starts = Vec::new(); + let mut chars_seen = 0usize; + for (rel_byte, ch) in insert.char_indices() { + chars_seen += 1; + if ch == '\n' { + line_starts.push((start_byte + rel_byte + 1) as u64); + line_char_starts.push((start_char + chars_seen) as u64); + } + } + (line_starts, line_char_starts) +} + +fn shift_u64(value: &mut u64, delta: i64) { + if delta >= 0 { + *value = value.saturating_add(delta as u64); + } else { + *value = value.saturating_sub(delta.unsigned_abs()); + } +} + +fn signed_usize_delta(new_len: usize, old_len: usize) -> Result { + let new_len = i64::try_from(new_len).map_err(|_| "new length exceeds i64")?; + let old_len = i64::try_from(old_len).map_err(|_| "old length exceeds i64")?; + Ok(new_len - old_len) +} + +fn translate_style_spans(spans: &mut Vec, edit: TextProjectionEdit) { + let mut translated = Vec::with_capacity(spans.len()); + for mut span in spans.drain(..) { + if let Some(range) = translate_byte_range(span.range, edit) { + span.range = range; + translated.push(span); + } + } + *spans = translated; +} + +fn translate_decorations(decorations: &mut Vec, edit: TextProjectionEdit) { + let mut translated = Vec::with_capacity(decorations.len()); + for mut decoration in decorations.drain(..) { + if let Some(range) = translate_byte_range(decoration.range, edit) { + decoration.range = range; + translated.push(decoration); + } + } + *decorations = translated; +} + +fn translate_inline_adornments(adornments: &mut [InlineAdornment], edit: TextProjectionEdit) { + for adornment in adornments { + adornment.at = translate_byte_position(adornment.at, edit); + } +} + +fn translate_byte_range(range: ByteRange, edit: TextProjectionEdit) -> Option { + let start = translate_range_start(range.start, edit); + let end = translate_range_end(range.end, edit); + (start < end).then_some(ByteRange { start, end }) +} + +fn translate_range_start(pos: u64, edit: TextProjectionEdit) -> u64 { + if edit.old_end == edit.start { + if pos >= edit.start { + pos.saturating_add(edit.inserted_len) + } else { + pos + } + } else if pos <= edit.start { + pos + } else if pos >= edit.old_end { + shift_position(pos, edit) + } else { + edit.start + } +} + +fn translate_range_end(pos: u64, edit: TextProjectionEdit) -> u64 { + if edit.old_end == edit.start { + // `>=` (not `>`): a range ending exactly at a pure-insert + // point *extends over* the inserted text. Typing at the end + // of a token is the dominant editing case, and inheriting the + // preceding span's color keeps the new char stably colored + // instead of blinking default-white until the next parse + // settles. (The start counterpart keeps `>=` shifting right, + // so a following span never overlaps the extension.) + if pos >= edit.start { + pos.saturating_add(edit.inserted_len) + } else { + pos + } + } else if pos <= edit.start { + pos + } else if pos >= edit.old_end { + shift_position(pos, edit) + } else { + edit.start.saturating_add(edit.inserted_len) + } +} + +fn translate_byte_position(pos: u64, edit: TextProjectionEdit) -> u64 { + if edit.old_end == edit.start { + if pos >= edit.start { + pos.saturating_add(edit.inserted_len) + } else { + pos + } + } else if pos <= edit.start { + pos + } else if pos >= edit.old_end { + shift_position(pos, edit) + } else { + edit.start.saturating_add(edit.inserted_len) + } +} + +fn shift_position(pos: u64, edit: TextProjectionEdit) -> u64 { + let old_len = edit.old_end.saturating_sub(edit.start); + if edit.inserted_len >= old_len { + pos.saturating_add(edit.inserted_len - old_len) + } else { + pos.saturating_sub(old_len - edit.inserted_len) + } } /// Byte range `[start, end)` of the source line containing `cursor`: @@ -1654,14 +3210,22 @@ fn projected_rich_chunks( adornments: &[InlineAdornment], ) -> Vec { let text_len = text.len() as u64; + // Every boundary used to slice `text` must be snapped to a UTF-8 + // char boundary. Span / decoration / adornment offsets come from + // the daemon for a possibly-earlier generation than the rope this + // frame holds (the one-frame edit race), so a raw offset can land + // inside a multi-byte char and panic the slice. Flooring to the + // previous char boundary is safe: it only shifts a chunk edge left + // to the start of the codepoint it fell inside. + let snap = |b: u64| floor_char_boundary(text, b.min(text_len) as usize) as u64; let mut boundaries: Vec = vec![0, text_len]; for sp in spans { - boundaries.push(sp.range.start.min(text_len)); - boundaries.push(sp.range.end.min(text_len)); + boundaries.push(snap(sp.range.start)); + boundaries.push(snap(sp.range.end)); } for d in decorations { - boundaries.push(d.range.start.min(text_len)); - boundaries.push(d.range.end.min(text_len)); + boundaries.push(snap(d.range.start)); + boundaries.push(snap(d.range.end)); } let mut renderable_adornments: Vec<(usize, u64, &InlineAdornment)> = adornments .iter() @@ -1669,7 +3233,7 @@ fn projected_rich_chunks( .filter_map(|(idx, a)| renderable_adornment_anchor(a, text_len).map(|at| (idx, at, a))) .collect(); for (_, at, _) in &renderable_adornments { - boundaries.push(*at); + boundaries.push(snap(*at)); } boundaries.sort_unstable(); boundaries.dedup(); @@ -1818,6 +3382,21 @@ fn indexed_to_glyphon(idx: u8) -> glyphon::Color { glyphon::Color::rgb(level, level, level) } +/// The decorations that affect the *rich text* (a glyph fg override in +/// `projected_rich_chunks`), as an ordered `(range, kind)` set. Only +/// kinds with a foreground color qualify — i.e. the diagnostic +/// severities; background kinds (`Selection` / `CurrentLine` / search) +/// are quads. Equal fingerprints across a `Decorations` update mean the +/// shaped text is unaffected and a `reshape()` can be skipped (the perf +/// fix for cursor-motion-driven `CurrentLine` churn). +fn fg_decoration_fingerprint(decos: &[Decoration]) -> Vec<(ByteRange, DecorationKind)> { + decos + .iter() + .filter(|d| decoration_kind_to_color(d.kind).is_some()) + .map(|d| (d.range, d.kind)) + .collect() +} + /// Map a [`DecorationKind`] to a foreground color override, or `None` /// for kinds whose visual is a background and can't be expressed in /// the current `Attrs`-only rendering pipeline. @@ -1959,6 +3538,130 @@ mod tests { assert_eq!(source_line_range(text, 99), (7, 10)); } + #[test] + fn translate_key_maps_motion_named_keys_and_chars() { + use winit::keyboard::{Key as WKey, ModifiersState, NamedKey, SmolStr}; + + let none = ModifiersState::empty(); + // Motion named keys translate and are gated as motion. + for (named, expected) in [ + (NamedKey::ArrowLeft, ProtocolKey::Left), + (NamedKey::ArrowRight, ProtocolKey::Right), + (NamedKey::ArrowUp, ProtocolKey::Up), + (NamedKey::ArrowDown, ProtocolKey::Down), + (NamedKey::Home, ProtocolKey::Home), + (NamedKey::End, ProtocolKey::End), + (NamedKey::PageUp, ProtocolKey::PageUp), + (NamedKey::PageDown, ProtocolKey::PageDown), + ] { + let (k, m) = translate_key(&WKey::Named(named), none).expect("named maps"); + assert_eq!(k, expected); + assert!(m.is_empty()); + assert!(is_motion_key(k), "{expected:?} should gate as motion"); + } + + // A character key maps to Char but is NOT a motion key (B1 + // gates it out; B2 opens it). + let (k, _) = translate_key(&WKey::Character(SmolStr::new("a")), none).expect("char maps"); + assert_eq!(k, ProtocolKey::Char('a')); + assert!(!is_motion_key(k)); + + // Editing named keys translate (for B2) but don't gate as motion. + let (bk, _) = translate_key(&WKey::Named(NamedKey::Backspace), none).expect("bksp maps"); + assert_eq!(bk, ProtocolKey::Backspace); + assert!(!is_motion_key(bk)); + + let (space, _) = translate_key(&WKey::Named(NamedKey::Space), none).expect("space maps"); + assert_eq!(space, ProtocolKey::Char(' ')); + assert!(!is_motion_key(space)); + } + + #[test] + fn should_forward_key_gates_editing_keys_and_excludes_chords() { + let none = Modifiers::NONE; + let ctrl = Modifiers::CTRL; + let shift = Modifiers::SHIFT; + + // Plain text-editing keys forward. + for key in [ + ProtocolKey::Char('a'), + ProtocolKey::Char('A'), + ProtocolKey::Backspace, + ProtocolKey::Enter, + ProtocolKey::Delete, + ProtocolKey::Tab, + ] { + assert!(should_forward_key(key, none), "{key:?} should forward"); + } + // Shift is not a chord modifier (Shift+a already arrives as 'A'). + assert!(should_forward_key(ProtocolKey::Char('A'), shift)); + + // Ctrl/Alt/Meta + a non-motion key is a chord — withheld in B2. + assert!(!should_forward_key(ProtocolKey::Char('x'), ctrl)); + assert!(!should_forward_key(ProtocolKey::Char('f'), Modifiers::ALT)); + assert!(!should_forward_key( + ProtocolKey::Char('h'), + Modifiers::HYPER + )); + + // Motion keys forward regardless of modifiers (C-Left = word-left). + assert!(should_forward_key(ProtocolKey::Left, ctrl)); + assert!(should_forward_key(ProtocolKey::Down, shift)); + assert!(should_forward_key(ProtocolKey::PageUp, none)); + + // Deletion keys forward regardless of modifiers too — C-BS / + // C-DEL / M-BS are word-level deletes in the default keymap, + // the same editing-command family as chorded motion. + assert!(should_forward_key(ProtocolKey::Backspace, ctrl)); + assert!(should_forward_key(ProtocolKey::Delete, ctrl)); + assert!(should_forward_key(ProtocolKey::Backspace, Modifiers::ALT)); + } + + #[test] + fn translate_key_carries_modifiers() { + use winit::keyboard::{Key as WKey, ModifiersState, NamedKey}; + + let ctrl = ModifiersState::CONTROL; + let (k, m) = translate_key(&WKey::Named(NamedKey::ArrowLeft), ctrl).expect("maps"); + assert_eq!(k, ProtocolKey::Left); + assert!(m.contains(Modifiers::CTRL)); + assert!(!m.contains(Modifiers::SHIFT)); + } + + #[test] + fn fg_fingerprint_ignores_background_decoration_changes() { + let deco = |start, end, kind| Decoration { + range: ByteRange { start, end }, + kind, + }; + // A diagnostic (fg) decoration + a CurrentLine (bg) decoration. + let before = vec![ + deco(10, 14, DecorationKind::DiagnosticError), + deco(0, 20, DecorationKind::CurrentLine), + ]; + // The cursor moved: CurrentLine now spans a different line, the + // diagnostic is unchanged. + let after = vec![ + deco(10, 14, DecorationKind::DiagnosticError), + deco(40, 60, DecorationKind::CurrentLine), + ]; + assert_eq!( + fg_decoration_fingerprint(&before), + fg_decoration_fingerprint(&after), + "a CurrentLine-only change must not change the fg fingerprint (no reshape)" + ); + + // A diagnostic change DOES alter the fingerprint (reshape needed). + let after_diag = vec![ + deco(10, 18, DecorationKind::DiagnosticError), + deco(0, 20, DecorationKind::CurrentLine), + ]; + assert_ne!( + fg_decoration_fingerprint(&before), + fg_decoration_fingerprint(&after_diag) + ); + } + #[test] fn line_byte_offsets_indexes_each_logical_line() { // "abc\nde\nfgh": lines start at bytes 0, 4, 7. Indexed by @@ -1971,6 +3674,320 @@ mod tests { assert_eq!(line_byte_offsets(""), vec![0]); } + #[test] + fn line_char_offsets_track_unicode_line_starts() { + let text = "aé\n😀b\n"; + let (line_starts, line_char_starts) = line_offset_tables(text); + assert_eq!(line_starts, vec![0, 4, 10]); + assert_eq!(line_char_starts, vec![0, 3, 6]); + } + + #[test] + fn byte_offset_for_char_offset_scans_only_within_line() { + let text = "aé\n😀b"; + let (line_starts, line_char_starts) = line_offset_tables(text); + assert_eq!( + byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 0), + Some(0) + ); + assert_eq!( + byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 2), + Some(3) + ); + assert_eq!( + byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 3), + Some(4) + ); + assert_eq!( + byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 4), + Some(8) + ); + } + + #[test] + fn loro_text_delta_batch_inserts_multibyte_text_and_updates_lines() { + let mut text = "aé\nb".to_owned(); + let (mut line_starts, mut line_char_starts) = line_offset_tables(&text); + let delta = vec![ + loro::TextDelta::Retain { + retain: 3, + attributes: None, + }, + loro::TextDelta::Insert { + insert: "😀\n".to_owned(), + attributes: None, + }, + ]; + + let mut edits = Vec::new(); + apply_loro_text_delta_batch( + &mut text, + &mut line_starts, + &mut line_char_starts, + &delta, + &mut edits, + ) + .expect("delta applies"); + + assert_eq!(text, "aé\n😀\nb"); + assert_eq!((line_starts, line_char_starts), line_offset_tables(&text)); + assert_eq!( + edits, + vec![TextProjectionEdit { + start: 4, + old_end: 4, + inserted_len: "😀\n".len() as u64, + }] + ); + } + + #[test] + fn loro_text_delta_batch_deletes_across_unicode_lines() { + let mut text = "aé\n😀\nb".to_owned(); + let (mut line_starts, mut line_char_starts) = line_offset_tables(&text); + let delta = vec![ + loro::TextDelta::Retain { + retain: 1, + attributes: None, + }, + loro::TextDelta::Delete { delete: 3 }, + ]; + + let mut edits = Vec::new(); + apply_loro_text_delta_batch( + &mut text, + &mut line_starts, + &mut line_char_starts, + &delta, + &mut edits, + ) + .expect("delta applies"); + + assert_eq!(text, "a\nb"); + assert_eq!((line_starts, line_char_starts), line_offset_tables(&text)); + assert_eq!( + edits, + vec![TextProjectionEdit { + start: 1, + old_end: 8, + inserted_len: 0, + }] + ); + } + + #[test] + fn cached_style_ranges_translate_through_insertions() { + let edit = TextProjectionEdit { + start: 5, + old_end: 5, + inserted_len: 3, + }; + + assert_eq!( + translate_byte_range(ByteRange { start: 10, end: 14 }, edit), + Some(ByteRange { start: 13, end: 17 }), + "ranges after the insert shift right" + ); + assert_eq!( + translate_byte_range(ByteRange { start: 2, end: 10 }, edit), + Some(ByteRange { start: 2, end: 13 }), + "ranges containing the insert expand" + ); + assert_eq!( + translate_byte_range(ByteRange { start: 2, end: 5 }, edit), + Some(ByteRange { start: 2, end: 8 }), + "ranges ending exactly at the insert boundary extend over the typed \ + text — typed chars inherit the preceding token's color until the \ + next authoritative frame" + ); + } + + #[test] + fn optimistic_insert_text_covers_plain_chars_enter_and_tab() { + let mut buf = [0u8; 4]; + let none = Modifiers::NONE; + let shift = Modifiers::SHIFT; + let ctrl = Modifiers::CTRL; + + assert_eq!( + optimistic_insert_text(ProtocolKey::Char('a'), none, &mut buf), + Some("a") + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Char('É'), shift, &mut buf), + Some("É"), + "shifted printable chars stay optimistic (shift is how uppercase arrives)" + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Enter, none, &mut buf), + Some("\n"), + "RET is bound to buffer.newline = insert_char(10): identical to a self-insert" + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Tab, none, &mut buf), + Some("\t"), + "TAB is bound to buffer.tab = insert_char(9): identical to a self-insert" + ); + + // Modified Enter/Tab and chords round-trip — a keymap may bind + // S-RET / C-TAB to anything. + assert_eq!( + optimistic_insert_text(ProtocolKey::Enter, shift, &mut buf), + None + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Tab, ctrl, &mut buf), + None + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Char('x'), ctrl, &mut buf), + None + ); + // Deletions and motion still round-trip. + assert_eq!( + optimistic_insert_text(ProtocolKey::Backspace, none, &mut buf), + None + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Left, none, &mut buf), + None + ); + } + + #[test] + fn optimistic_delete_range_covers_single_codepoints_only() { + let none = Modifiers::NONE; + let text = "aé😀b"; + + // Backspace deletes the codepoint before the cursor, whatever + // its width: 'é' is 2 bytes, '😀' is 4. + assert_eq!( + optimistic_delete_range(text, 3, ProtocolKey::Backspace, none), + Some((1, 3)), + "backspace before the cursor crosses the full 'é'" + ); + assert_eq!( + optimistic_delete_range(text, 7, ProtocolKey::Backspace, none), + Some((3, 7)), + "backspace crosses the full '😀'" + ); + // Delete removes the codepoint at the cursor. + assert_eq!( + optimistic_delete_range(text, 1, ProtocolKey::Delete, none), + Some((1, 3)) + ); + assert_eq!( + optimistic_delete_range(text, 7, ProtocolKey::Delete, none), + Some((7, 8)) + ); + + // Buffer edges: nothing to delete ⇒ round-trip (daemon no-op). + assert_eq!( + optimistic_delete_range(text, 0, ProtocolKey::Backspace, none), + None + ); + assert_eq!( + optimistic_delete_range(text, text.len(), ProtocolKey::Delete, none), + None + ); + // Mid-codepoint (stale) cursor ⇒ round-trip, never a panic. + assert_eq!( + optimistic_delete_range(text, 2, ProtocolKey::Backspace, none), + None + ); + // Modified variants are separate bindings (C-BS word delete). + assert_eq!( + optimistic_delete_range(text, 3, ProtocolKey::Backspace, Modifiers::CTRL), + None + ); + // Non-delete keys are not this helper's business. + assert_eq!( + optimistic_delete_range(text, 3, ProtocolKey::Char('x'), none), + None + ); + } + + #[test] + fn incoming_frames_translate_through_unconfirmed_edits() { + // A frame computed at daemon generation G arrives while one + // local optimistic insert (scalar G+1: 3 bytes at byte 5) is + // still unconfirmed: the frame's ranges must shift through it. + let unconfirmed = vec![( + 11u64, + TextProjectionEdit { + start: 5, + old_end: 5, + inserted_len: 3, + }, + )]; + let segments = vec![StyleSegment { + range: ByteRange { start: 0, end: 20 }, + spans: vec![ + StyleSpan { + range: ByteRange { start: 2, end: 4 }, + style: CellStyle::default(), + }, + StyleSpan { + range: ByteRange { start: 10, end: 14 }, + style: CellStyle::default(), + }, + ], + }]; + + let translated = translate_style_segments(segments, &unconfirmed); + assert_eq!(translated.len(), 1); + assert_eq!( + translated[0].range, + ByteRange { start: 0, end: 23 }, + "segment range expands over the unconfirmed insert" + ); + assert_eq!( + translated[0].spans[0].range, + ByteRange { start: 2, end: 4 }, + "spans before the insert are untouched" + ); + assert_eq!( + translated[0].spans[1].range, + ByteRange { start: 13, end: 17 }, + "spans after the insert shift right by its length" + ); + + // With no unconfirmed edits the frame passes through as-is. + let untouched = translate_style_segments( + vec![StyleSegment { + range: ByteRange { start: 0, end: 20 }, + spans: Vec::new(), + }], + &[], + ); + assert_eq!(untouched[0].range, ByteRange { start: 0, end: 20 }); + } + + #[test] + fn cached_style_ranges_translate_through_deletions() { + let edit = TextProjectionEdit { + start: 5, + old_end: 9, + inserted_len: 0, + }; + + assert_eq!( + translate_byte_range(ByteRange { start: 12, end: 16 }, edit), + Some(ByteRange { start: 8, end: 12 }), + "ranges after the deletion shift left" + ); + assert_eq!( + translate_byte_range(ByteRange { start: 3, end: 12 }, edit), + Some(ByteRange { start: 3, end: 8 }), + "ranges spanning the deletion shrink" + ); + assert_eq!( + translate_byte_range(ByteRange { start: 6, end: 8 }, edit), + None, + "ranges fully removed by the deletion drop" + ); + } + #[test] fn source_line_range_handles_empty_and_leading_newline() { assert_eq!(source_line_range("", 0), (0, 0)); @@ -2034,6 +4051,51 @@ mod tests { } } + #[test] + fn projected_rich_chunks_tolerates_mid_codepoint_boundaries() { + // Stale span offsets (from a prior generation) can land inside a + // multi-byte char after an edit. "ab→cd": '→' is the 3 bytes + // [2,5); a span ending at byte 3 is mid-codepoint and must not + // panic the slice — it floors to the char start. + let text = "ab→cd"; + let chunks = projected_rich_chunks( + text, + &[span(0, 3, CellColor::Indexed(1))], + &[Decoration { + range: ByteRange { start: 4, end: 9 }, + kind: DecorationKind::DiagnosticError, + }], + &[], + ); + let rendered: String = chunks.iter().map(|chunk| chunk.text.as_str()).collect(); + assert_eq!(rendered, text, "chunks must reassemble the original text"); + } + + #[test] + fn clip_rebase_range_clips_to_slice_and_subtracts_vstart() { + // Visible slice is whole-file bytes [10, 20). + assert_eq!(clip_rebase_range(12, 18, 10, 20), Some((2, 8))); // inside + assert_eq!(clip_rebase_range(5, 15, 10, 20), Some((0, 5))); // clipped left + assert_eq!(clip_rebase_range(15, 25, 10, 20), Some((5, 10))); // clipped right + assert_eq!(clip_rebase_range(10, 20, 10, 20), Some((0, 10))); // exact + assert_eq!(clip_rebase_range(0, 8, 10, 20), None); // entirely before + assert_eq!(clip_rebase_range(20, 30, 10, 20), None); // entirely after + assert_eq!(clip_rebase_range(14, 14, 10, 20), None); // empty range + // vstart 0 is the unscrolled identity case. + assert_eq!(clip_rebase_range(3, 7, 0, 100), Some((3, 7))); + } + + #[test] + fn floor_char_boundary_snaps_into_multibyte_char() { + let text = "ab→cd"; // '→' = bytes [2,5) + assert_eq!(floor_char_boundary(text, 0), 0); + assert_eq!(floor_char_boundary(text, 2), 2); + assert_eq!(floor_char_boundary(text, 3), 2); // inside '→' → floor to 2 + assert_eq!(floor_char_boundary(text, 4), 2); + assert_eq!(floor_char_boundary(text, 5), 5); + assert_eq!(floor_char_boundary(text, 99), text.len()); + } + #[test] fn projected_rich_chunks_inserts_at_offset_without_source_bytes() { let chunks = projected_rich_chunks( diff --git a/src/buffer.rs b/src/buffer.rs index a145981..e01a683 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -684,16 +684,12 @@ impl Buffer { /// Used by the daemon's `FrontendEvent::CrdtOp` handler when a /// replica frontend forwards a CRDT op. The flow: /// - /// 1. Capture the rope's current bytes (rope ≡ CRDT projection - /// invariant — both have the same content pre-import). - /// 2. `crdt.import_updates(op_bytes)` — integrates the remote op - /// into the local CRDT state. CRDT convergence handles - /// concurrent edits. - /// 3. Materialize the post-import CRDT content. - /// 4. Compute the diff between pre- and post-content as a single - /// `Replace` `EditOp` (single insert/delete falls out as - /// Replace with empty inserted or empty range). - /// 5. Apply the rope stages (rope mutation + mark adjustment + + /// 1. `crdt.import_updates_with_text_deltas(op_bytes)` integrates + /// the remote op and captures Loro's text projection delta. + /// 2. Apply the common single-insert shape directly to the rope. + /// Deletes and compound updates conservatively fall back to a + /// post-import materialization + contiguous diff. + /// 3. Apply the rope stages (rope mutation + mark adjustment + /// revision bump + modified flag + `on_edit` broadcast). /// Skips the CRDT-application stage (already done in step 2) /// AND the undo push (remote ops aren't locally undoable per @@ -750,25 +746,55 @@ impl Buffer { }); }; - // Step 1: capture pre-import bytes (rope ≡ CRDT projection - // invariant means rope.slice == crdt.materialize_string here). + // Integrate the remote op and capture Loro's projection diff. + // Optimistic GUI typing produces one Insert delta, so handle + // that shape without copying or materializing the document. + let text_deltas = crdt + .import_updates_with_text_deltas(op_bytes) + .map_err(|e| BufferError::CrdtRejected { + reason: format!("import_updates: {e:?}"), + })?; + if let Some((unicode_pos, inserted)) = single_remote_text_insert(&text_deltas) + && let Some(byte_pos) = crdt.unicode_to_utf8_pos(unicode_pos) + { + let byte_pos = byte_pos as Position; + let mut views = std::mem::take(&mut self.views); + let result = + self.run_remote_rope_stages(&mut views, byte_pos, byte_pos, inserted.as_bytes()); + self.views = views; + return result.map(Some); + } + + // Single-delete hot path (optimistic Backspace/Delete). The + // deletion's *start* converts through the post-import doc — + // the prefix is untouched, so the byte offset is identical + // pre- and post-import. The *end* byte cannot (those chars + // are gone from the doc); it comes from walking the still + // pre-import rope over the deleted codepoint count. + if let Some((unicode_pos, deleted_chars)) = single_remote_text_delete(&text_deltas) + && let Some(byte_start) = crdt.unicode_to_utf8_pos(unicode_pos) + && let Some(byte_end) = + rope_byte_end_after_chars(&self.rope, byte_start as Position, deleted_chars) + { + let byte_start = byte_start as Position; + let mut views = std::mem::take(&mut self.views); + let result = self.run_remote_rope_stages(&mut views, byte_start, byte_end, b""); + self.views = views; + return result.map(Some); + } + + // Conservative fallback for compound updates and + // already-integrated ops. The rope is still the pre-import + // projection, so it remains the source for the old bytes. let old_len = self.rope.len(); let mut old_bytes = vec![0u8; old_len as usize]; if old_len > 0 { self.rope.slice(0, old_len, &mut old_bytes); } - - // Step 2: integrate the remote op into the CRDT state. - crdt.import_updates(op_bytes) - .map_err(|e| BufferError::CrdtRejected { - reason: format!("import_updates: {e:?}"), - })?; - - // Step 3: materialize the post-import content. let new_content = crdt.materialize_string(); let new_bytes = new_content.as_bytes(); - // Step 4: compute common prefix/suffix at byte level, then + // Compute common prefix/suffix at byte level, then // **back off to UTF-8 char boundaries** in both strings. // // # Post-audit-round-4 F25: char-boundary alignment @@ -829,7 +855,7 @@ impl Buffer { let range_end = (old_bytes.len() - suffix) as Position; let inserted = &new_bytes[prefix..new_bytes.len() - suffix]; - // Step 5: apply rope stages without re-applying to CRDT + // Apply rope stages without re-applying to CRDT // (CRDT was applied above in step 2) and without undo push // (remote ops aren't locally undoable per M10.4). let mut views = std::mem::take(&mut self.views); @@ -1451,6 +1477,103 @@ impl Buffer { #[cfg(feature = "crdt")] type CrdtRoutingResult = (Option>, Option>); +/// Recognize the hot-path projection delta produced by one remote +/// insertion. Loro's retain/delete lengths use Unicode scalar offsets; +/// the caller converts the insertion point through the post-import +/// text container before applying the UTF-8 bytes to the rope. +#[cfg(feature = "crdt")] +fn single_remote_text_insert(deltas: &[Vec]) -> Option<(usize, &str)> { + let [delta] = deltas else { + return None; + }; + let mut cursor = 0usize; + let mut found = None; + for op in delta { + match op { + loro::TextDelta::Retain { retain, .. } => { + cursor = cursor.checked_add(*retain)?; + } + loro::TextDelta::Insert { insert, .. } if insert.is_empty() => {} + loro::TextDelta::Insert { insert, .. } => { + if found.is_some() { + return None; + } + found = Some((cursor, insert.as_str())); + cursor = cursor.checked_add(insert.chars().count())?; + } + loro::TextDelta::Delete { delete } if *delete == 0 => {} + loro::TextDelta::Delete { .. } => return None, + } + } + found +} + +/// Recognize the hot-path projection delta produced by one remote +/// deletion: an optional leading `Retain` followed by exactly one +/// `Delete`, nothing else. Returns `(unicode_start, deleted_chars)` +/// in Unicode scalar units. +#[cfg(feature = "crdt")] +fn single_remote_text_delete(deltas: &[Vec]) -> Option<(usize, usize)> { + let [delta] = deltas else { + return None; + }; + let mut cursor = 0usize; + let mut found = None; + for op in delta { + match op { + loro::TextDelta::Retain { retain, .. } => { + cursor = cursor.checked_add(*retain)?; + } + loro::TextDelta::Insert { insert, .. } if insert.is_empty() => {} + loro::TextDelta::Insert { .. } => return None, + loro::TextDelta::Delete { delete } if *delete == 0 => {} + loro::TextDelta::Delete { delete } => { + if found.is_some() { + return None; + } + found = Some((cursor, *delete)); + } + } + } + found +} + +/// Byte offset just past `chars` codepoints starting at `byte_start` +/// in `rope`. Reads at most `chars * 4` bytes (one UTF-8 max-width +/// each), so a Backspace-sized walk touches a handful of bytes, not +/// the file. `None` when the rope runs out (or a boundary is off) — +/// callers fall back to the materialize-and-diff path. +#[cfg(feature = "crdt")] +fn rope_byte_end_after_chars( + rope: &crate::rope::Rope, + byte_start: Position, + chars: usize, +) -> Option { + let len = rope.len(); + if byte_start > len || chars == 0 { + return None; + } + let take = (chars as Position).saturating_mul(4).min(len - byte_start); + let mut buf = vec![0u8; take as usize]; + rope.slice(byte_start, byte_start + take, &mut buf); + let s = match std::str::from_utf8(&buf) { + Ok(s) => s, + // The 4*chars window can cut a trailing codepoint that we + // don't need anyway; keep the valid prefix. + Err(e) => std::str::from_utf8(&buf[..e.valid_up_to()]).ok()?, + }; + let mut remaining = chars; + let mut offset = 0usize; + for ch in s.chars() { + if remaining == 0 { + break; + } + offset += ch.len_utf8(); + remaining -= 1; + } + (remaining == 0).then(|| byte_start + offset as Position) +} + /// T M10.4: derive a fine-grained `(range, inserted_len)` Edit /// description for the change from `old_rope` to `new_rope` via /// longest-common-prefix + longest-common-suffix trim. @@ -2884,6 +3007,70 @@ mod tests { assert_eq!(count.get(), 1, "on_edit must fire for remote op"); } + #[cfg(feature = "crdt")] + #[test] + fn apply_remote_crdt_op_insert_after_multibyte_char_uses_utf8_byte_position() { + let donor = crate::crdt::CrdtState::new(2).expect("donor"); + donor.insert(0, "éx").expect("seed"); + + let mut buf = Buffer::new_with_crdt(BufferId::next(), "*utf8-insert*", 1).expect("buf"); + let donor_snap = donor.export_snapshot().expect("snap"); + buf.crdt + .as_ref() + .expect("crdt") + .import_snapshot(&donor_snap) + .expect("init from snap"); + buf.rope = crate::rope::Rope::from_bytes("éx".as_bytes()); + + let v_before = donor.version(); + donor.insert("é".len(), "!").expect("insert"); + let op_bytes = donor.export_updates_since(&v_before).expect("export"); + let edit = buf + .apply_remote_crdt_op(&op_bytes) + .expect("apply") + .expect("non-empty edit"); + + assert_eq!(rope_string(&buf), "é!x"); + assert_eq!(edit.range, Range::new("é".len() as u64, "é".len() as u64)); + assert_eq!(edit.inserted_len, 1); + assert_invariant(&buf); + } + + #[cfg(feature = "crdt")] + #[test] + fn apply_remote_crdt_op_single_delete_uses_pre_import_rope_for_end_byte() { + let donor = crate::crdt::CrdtState::new(2).expect("donor"); + donor.insert(0, "aéx").expect("seed"); + + let mut buf = Buffer::new_with_crdt(BufferId::next(), "*utf8-delete*", 1).expect("buf"); + let donor_snap = donor.export_snapshot().expect("snap"); + buf.crdt + .as_ref() + .expect("crdt") + .import_snapshot(&donor_snap) + .expect("init from snap"); + buf.rope = crate::rope::Rope::from_bytes("aéx".as_bytes()); + + // Delete the 2-byte 'é' (CrdtState::delete takes UTF-8 byte + // offsets; the wire delta reports it as 1 Unicode scalar). + let v_before = donor.version(); + donor.delete(1, "é".len()).expect("delete"); + let op_bytes = donor.export_updates_since(&v_before).expect("export"); + let edit = buf + .apply_remote_crdt_op(&op_bytes) + .expect("apply") + .expect("non-empty edit"); + + assert_eq!(rope_string(&buf), "ax"); + assert_eq!( + edit.range, + Range::new(1, 1 + "é".len() as u64), + "byte range covers the multibyte codepoint exactly" + ); + assert_eq!(edit.inserted_len, 0); + assert_invariant(&buf); + } + /// F25 (post-audit-round-4): a CRDT update that changes one /// codepoint into another with a shared leading UTF-8 byte /// must produce a char-boundary-aligned diff. Pre-fix, the diff --git a/src/crdt.rs b/src/crdt.rs index c7d493c..8cef9e3 100644 --- a/src/crdt.rs +++ b/src/crdt.rs @@ -40,7 +40,18 @@ //! propagation, the optional `crdt_op` field on [`crate::rope::Edit`], //! and the convergence proptest. -use loro::{ExportMode, LoroDoc, LoroEncodeError, LoroResult, UndoManager, VersionVector}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; + +use loro::{ + ContainerTrait, ExportMode, LoroDoc, LoroEncodeError, LoroResult, TextDelta, UndoManager, + VersionVector, +}; + +type TextDeltaBatches = Arc>>>; +type TextDeltaSubscription = (TextDeltaBatches, Arc, loro::Subscription); /// The CRDT-backed buffer state. /// @@ -54,6 +65,12 @@ use loro::{ExportMode, LoroDoc, LoroEncodeError, LoroResult, UndoManager, Versio /// (foreground worker materializes the initial projection). pub struct CrdtState { doc: LoroDoc, + /// Text projection deltas captured only while a remote import is + /// active. Keeping the subscription alive avoids registering and + /// dropping one callback for every typed character. + text_delta_batches: TextDeltaBatches, + text_delta_capture_enabled: Arc, + _text_delta_subscription: loro::Subscription, /// T M10.4: per-peer undo machinery. Bound to `doc`'s `peer_id` /// at construction; produces inverse ops attributed to that peer. /// @@ -90,13 +107,45 @@ impl CrdtState { // explicit get here ensures the container is registered before // any read or write. let _ = doc.get_text("body"); + let (text_delta_batches, text_delta_capture_enabled, text_delta_subscription) = + Self::subscribe_text_deltas(&doc); let undo = Self::create_undo_manager(&doc); Ok(Self { doc, + text_delta_batches, + text_delta_capture_enabled, + _text_delta_subscription: text_delta_subscription, undo: std::cell::RefCell::new(undo), }) } + fn subscribe_text_deltas(doc: &LoroDoc) -> TextDeltaSubscription { + let text = doc.get_text("body"); + let batches = Arc::new(Mutex::new(Vec::>::new())); + let capture_enabled = Arc::new(AtomicBool::new(false)); + let captured_batches = Arc::clone(&batches); + let captured_enabled = Arc::clone(&capture_enabled); + let subscription = doc.subscribe( + &text.id(), + Arc::new(move |event| { + if !captured_enabled.load(Ordering::Relaxed) { + return; + } + let mut guard = captured_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for event in event.events { + if let Some(delta) = event.diff.as_text() + && !delta.is_empty() + { + guard.push(delta.clone()); + } + } + }), + ); + (batches, capture_enabled, subscription) + } + /// T M10.4: construct a fresh `UndoManager` bound to the given doc. /// Extracted as a helper because `from_bytes` constructs it AFTER /// the initial seed insert (so the seed isn't observable as an @@ -136,9 +185,14 @@ impl CrdtState { // The buffer's starting contents (from file load, scratch // initial text, etc.) shouldn't be undoable from the user's // perspective; only post-construction edits are. + let (text_delta_batches, text_delta_capture_enabled, text_delta_subscription) = + Self::subscribe_text_deltas(&doc); let undo = Self::create_undo_manager(&doc); Ok(Self { doc, + text_delta_batches, + text_delta_capture_enabled, + _text_delta_subscription: text_delta_subscription, undo: std::cell::RefCell::new(undo), }) } @@ -302,6 +356,41 @@ impl CrdtState { self.doc.import(bytes).map(|_| ()) } + /// Import remote updates and capture Loro's text projection deltas. + /// + /// The import callback runs synchronously before `doc.import` + /// returns. Buffer's hot path uses the captured single-insert shape + /// to update its rope projection without materializing the whole + /// document. + pub fn import_updates_with_text_deltas(&self, bytes: &[u8]) -> LoroResult>> { + self.text_delta_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + self.text_delta_capture_enabled + .store(true, Ordering::Relaxed); + let import_result = self.doc.import(bytes).map(|_| ()); + self.text_delta_capture_enabled + .store(false, Ordering::Relaxed); + import_result?; + let mut guard = self + .text_delta_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Ok(std::mem::take(&mut *guard)) + } + + /// Convert a Unicode scalar offset in the current text projection + /// to its UTF-8 byte offset. + #[must_use] + pub fn unicode_to_utf8_pos(&self, pos: usize) -> Option { + self.doc.get_text("body").convert_pos( + pos, + loro::cursor::PosType::Unicode, + loro::cursor::PosType::Bytes, + ) + } + /// T M10.10 post-audit-round-4 F26 — validate that importing /// the wire bytes `bytes` would attribute every new op to /// `expected_peer_id`. diff --git a/src/daemon.rs b/src/daemon.rs index 1f928a0..a1a0a93 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1246,6 +1246,7 @@ fn handle_session_established( } #[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_lines)] // per-variant dispatcher match. fn handle_dispatcher_event( event: DispatcherEvent, editor: &mut EditorState, @@ -1342,8 +1343,20 @@ fn handle_dispatcher_event( // session never sends this; if one does, there is // no `SemanticRenderState` to update and it is a // benign no-op. - if let Some(sem) = semantic_states.get_mut(&source) { - sem.set_viewport(buffer_id, visible, generation); + if semantic_states.contains_key(&source) { + // Phase B (B1) — the Viewport declares *which + // buffer this frontend is displaying*. Align its + // editor window to that buffer so keyboard input + // (`dispatch_key`) and the `CursorByte` it emits + // target the displayed buffer. Without this, a + // semantic frontend's window stays bound to + // LOCAL's attach-time buffer (often a scratch the + // user isn't viewing), so arrow keys moved an + // off-screen cursor and the caret never tracked. + align_semantic_window_to_buffer(editor, source, buffer_id); + if let Some(sem) = semantic_states.get_mut(&source) { + sem.set_viewport(buffer_id, visible, generation); + } } } _ => { @@ -1354,15 +1367,22 @@ fn handle_dispatcher_event( if let Some(render_state) = render_states.get_mut(&source) { apply_event(editor, event, &mut term_size, render_state); term_sizes.insert(source, term_size); + } else if semantic_states.contains_key(&source) { + // Phase B (session B1) — a semantic (grid-less) + // session has no `RenderState`, but its keyboard + // input still drives the shared editor core. The + // input events that don't need grid state + // (`Key`, `Mouse`) dispatch through the same + // `dispatch_key` / `dispatch_mouse` path the TUI + // uses; the resulting cursor move / edit flows + // back as `CursorByte` / `CrdtOp`. (Earlier this + // arm dropped these events — the "M11.5 scope" + // posture — which is why typing in pmacs-gpu did + // nothing before B1.) + apply_semantic_input_event(editor, event, term_size); } else { - // T M11.2 — a semantic (grid-less) session has - // no `RenderState`. Key/Mouse/Paste/Focus - // command handling for semantic frontends is - // M11.5 scope; until then these events are - // dropped rather than panicking the - // dispatcher on the absent grid state. debug_assert!( - semantic_states.contains_key(&source), + false, "fid with neither a render_state nor a semantic_state \ sent a frontend event" ); @@ -1836,6 +1856,53 @@ fn handle_remote_crdt_op( /// `pmacs.editor.open(path)` to switch their window to a different /// buffer; the per-frontend window-tree refactor (M10.8 Q1) makes /// this independent. +/// Re-point a semantic frontend's active window at `buffer_id` — the +/// buffer it just declared (via `FrontendEvent::Viewport`) that it is +/// displaying. No-op when the window is already on that buffer or the +/// buffer is gone. +/// +/// A semantic frontend renders from the wire (`StyleSpans` + its local +/// CRDT replica), so its daemon-side window holds only the cursor and +/// the buffer identity — no grid overlays to migrate. Rebuilding the +/// `TextView` (a cheap line index) and resetting the cursor is the +/// whole switch. This is the input/display alignment fix for B1: the +/// frontend's *declared* buffer becomes the buffer its keys edit and +/// its `CursorByte` reports. +fn align_semantic_window_to_buffer( + editor: &mut EditorState, + fid: FrontendId, + buffer_id: crate::buffer::BufferId, +) { + use crate::text_view::TextView; + + let text_view = { + let core = editor.core.borrow(); + let Some(win_id) = core.views.get(&fid).map(|v| v.active) else { + return; + }; + if core.windows.get(&win_id).map(|w| w.buffer_id) == Some(buffer_id) { + return; // Already displaying this buffer. + } + let reg = core.registry.borrow(); + let Ok(buf) = reg.get(buffer_id) else { + return; // Unknown buffer — leave the window as-is. + }; + TextView::new(buf) + }; + + let mut core = editor.core.borrow_mut(); + let Some(win_id) = core.views.get(&fid).map(|v| v.active) else { + return; + }; + if let Some(win) = core.windows.get_mut(&win_id) { + win.buffer_id = buffer_id; + win.text_view = text_view; + win.cursor = 0; + win.selection = None; + win.overlays.clear(); + } +} + fn build_fresh_frontend_view(editor: &mut EditorState) -> crate::window::FrontendView { use crate::text_view::TextView; use crate::window::{FrontendView, Layout, Window, WindowId}; @@ -1897,6 +1964,30 @@ fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> Pre } } +/// Dispatch a semantic (grid-less) frontend's input event into the +/// shared editor core (Phase B, session B1). Mirrors the `Key` / `Mouse` +/// arms of [`apply_event`] but takes no `RenderState` — a semantic +/// frontend lays out locally, so the only state these events touch is +/// the editor core (cursor, buffer, commands), which `dispatch_key` / +/// `dispatch_mouse` operate on directly. `Resize` / `Paste` / `Focus` +/// have no grid-less effect yet and are dropped; `Viewport` / `CrdtOp` +/// are handled in their own dispatcher arms and never reach here. +#[allow(clippy::needless_pass_by_value)] // consumes the event, mirroring `apply_event`. +fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_size: CellSize) { + match ev { + FrontendEvent::Key(pmacs_key) => { + if let Some(ct_key) = key_to_crossterm(&pmacs_key) { + editor.dispatch_key(pmacs_key.frontend_id, ct_key); + } + } + FrontendEvent::Mouse(pmacs_mouse) => { + let ct_mouse = mouse_to_crossterm(&pmacs_mouse); + editor.dispatch_mouse(pmacs_mouse.frontend_id, ct_mouse, term_size); + } + _ => {} + } +} + // Takes `ev` by value because it semantically consumes the event; // the caller pulls events out of the channel one at a time and never // needs to look at them again. @@ -2100,4 +2191,123 @@ mod tests { "buffer.after-edit must fire when handle_remote_crdt_op produces a text Edit" ); } + + /// Session B1 regression: a `Key` event from a *semantic* + /// (grid-less) frontend must reach the editor core. Before B1 the + /// dispatcher's catch-all only called `apply_event` when the + /// frontend had a `RenderState`, so a semantic frontend's keys were + /// silently dropped — typing in pmacs-gpu did nothing. The routing + /// now goes through `apply_semantic_input_event`; a printable char + /// must self-insert at the frontend's window cursor. + #[cfg(feature = "crdt")] + #[test] + fn semantic_frontend_key_event_reaches_the_core() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + use pmacs_protocol::{Key, KeyEvent, Modifiers}; + + let mut editor = EditorState::new(); + let fid = FrontendId(99); + let view = build_fresh_frontend_view(&mut editor); + editor.core.borrow_mut().register_frontend_view(fid, view); + + let before = editor + .core + .borrow() + .active_window_for(fid) + .expect("fid window") + .cursor; + + apply_semantic_input_event( + &mut editor, + FrontendEvent::Key(KeyEvent { + frontend_id: fid, + key: Key::Char('X'), + mods: Modifiers::NONE, + timestamp_ns: 0, + }), + CellSize::new(24, 80), + ); + + let after = editor + .core + .borrow() + .active_window_for(fid) + .expect("fid window") + .cursor; + assert_eq!( + after, + before + 1, + "a semantic frontend's printable Key must self-insert and advance its window cursor \ + (pre-B1 the dispatcher dropped it)" + ); + } + + /// B1 input/display alignment: a semantic frontend's window is bound + /// to LOCAL's attach-time buffer, but the buffer it *displays* is + /// the one it declares via `Viewport`. `align_semantic_window_to_buffer` + /// re-points the window so keys edit the displayed buffer — without + /// it, arrow keys moved an off-screen cursor in the wrong buffer and + /// the caret never tracked. + #[cfg(feature = "crdt")] + #[test] + fn viewport_aligns_semantic_window_to_displayed_buffer() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + use pmacs_protocol::{Key, KeyEvent, Modifiers}; + + let mut editor = EditorState::new(); + let scratch = editor.core.borrow().active_window().buffer_id; + let file = { + let core = editor.core.borrow(); + core.registry + .borrow_mut() + .create_from_bytes("file".to_owned(), b"hello\nworld\n") + }; + assert_ne!(scratch, file); + + // Attach: window shares LOCAL's active (scratch). + let fid = FrontendId(99); + let view = build_fresh_frontend_view(&mut editor); + editor.core.borrow_mut().register_frontend_view(fid, view); + assert_eq!( + editor + .core + .borrow() + .active_window_for(fid) + .unwrap() + .buffer_id, + scratch + ); + + // The frontend declares it is displaying the file buffer. + align_semantic_window_to_buffer(&mut editor, fid, file); + assert_eq!( + editor + .core + .borrow() + .active_window_for(fid) + .unwrap() + .buffer_id, + file, + "Viewport must re-point the window at the displayed buffer" + ); + + // A key now edits the *displayed* buffer, advancing its cursor. + apply_semantic_input_event( + &mut editor, + FrontendEvent::Key(KeyEvent { + frontend_id: fid, + key: Key::Char('Z'), + mods: Modifiers::NONE, + timestamp_ns: 0, + }), + CellSize::new(24, 80), + ); + assert_eq!( + editor.core.borrow().active_window_for(fid).unwrap().cursor, + 1, + "key must self-insert into the displayed buffer, not the attach-time scratch" + ); + } } diff --git a/src/editor.rs b/src/editor.rs index 6b20371..d5999bf 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -16,7 +16,7 @@ use std::cell::RefCell; use std::io; use std::path::PathBuf; use std::rc::Rc; -use std::time::Duration; +use std::time::{Duration, Instant}; use crossterm::event::{KeyCode, KeyModifiers}; @@ -97,8 +97,21 @@ pub struct EditorState { /// Snippet store (T M4.11). Co-owned with the snippet /// provider closure inside [`Self::completion_registry`]. pub snippets: crate::completion_framework::SharedSnippetRegistry, + /// Last left-button down event, used to synthesize terminal double + /// clicks from crossterm's plain Down/Up mouse event stream. + mouse_click: Option, } +#[derive(Copy, Clone)] +struct MouseClickState { + frontend_id: FrontendId, + window_id: WindowId, + cell: CellCoord, + at: Instant, +} + +const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500); + impl EditorState { /// Construct a fresh editor for an unnamed scratch buffer. /// @@ -322,6 +335,7 @@ impl EditorState { project_indexer, completion_registry, snippets, + mouse_click: None, } } @@ -662,6 +676,8 @@ impl EditorState { /// positions the buffer cursor at the corresponding rope /// position. Starts an empty selection at that position so /// a drag continues the region from there. + /// * A second `Down(Left)` in the same cell within the double-click + /// threshold selects the word at the click position. /// * `Drag(Left)` updates the cursor as the mouse moves; the /// anchor stays put, so the region grows. /// * `Up(Left)` ends a drag. If anchor and cursor coincide @@ -699,14 +715,28 @@ impl EditorState { match ev.kind { MouseEventKind::Down(MouseButton::Left) => { if local_row >= inner_rows { + self.mouse_click = None; return; // Mode-line click: reserved. } + let click_cell = CellCoord::new(cell_row, cell_col); + let is_double_click = self.is_double_click(frontend_id, win_id, click_cell); self.activate_and_position(win_id, local_row, local_col); - let mut core = self.core.borrow_mut(); - let pos = core.cursor(); - core.begin_selection(pos); + if is_double_click && self.core.borrow_mut().select_word_at_cursor() { + self.mouse_click = None; + } else { + let mut core = self.core.borrow_mut(); + let pos = core.cursor(); + core.begin_selection(pos); + self.mouse_click = Some(MouseClickState { + frontend_id, + window_id: win_id, + cell: click_cell, + at: Instant::now(), + }); + } } MouseEventKind::Drag(MouseButton::Left) => { + self.mouse_click = None; if local_row >= inner_rows { return; } @@ -721,15 +751,34 @@ impl EditorState { } } MouseEventKind::ScrollUp => { + self.mouse_click = None; self.scroll_window(win_id, -SCROLL_LINES); } MouseEventKind::ScrollDown => { + self.mouse_click = None; self.scroll_window(win_id, SCROLL_LINES); } - _ => {} + _ => { + self.mouse_click = None; + } } } + fn is_double_click( + &self, + frontend_id: FrontendId, + window_id: WindowId, + cell: CellCoord, + ) -> bool { + let Some(prev) = self.mouse_click else { + return false; + }; + prev.frontend_id == frontend_id + && prev.window_id == window_id + && prev.cell == cell + && prev.at.elapsed() <= DOUBLE_CLICK_MAX_DELAY + } + /// Make `win_id` the active window and place its cursor at the /// buffer position corresponding to `(local_row, local_col)`, /// where the coordinates are relative to the window's viewport @@ -1110,6 +1159,7 @@ pub fn paint_frame( for overlay in &mut window.overlays { overlay.render(buf, viewport, grid); } + paint_local_selection(grid, buf, window, &rect, inner_rows); // Mode line for this window. Painted last so the line // itself is always visible regardless of overlay activity. let coord = window @@ -1199,6 +1249,62 @@ fn inner_rows(rect: &crate::window::Rect) -> u32 { rect.size.rows.saturating_sub(1) } +fn paint_local_selection( + grid: &mut crate::cell::CellGrid<'_>, + buf: &crate::buffer::Buffer, + window: &crate::window::Window, + rect: &crate::window::Rect, + inner_rows: u32, +) { + let Some((sel_start, sel_end)) = window.region() else { + return; + }; + if inner_rows == 0 || rect.size.cols == 0 || sel_start >= sel_end { + return; + } + + let first_row = window.view_top; + let last_row = first_row.saturating_add(inner_rows as usize); + for display_row in first_row..last_row { + let Some(line_start) = window.text_view.line_offset(display_row) else { + continue; + }; + let Some(line_len) = window.text_view.line_len(buf, display_row) else { + continue; + }; + let line_end = line_start.saturating_add(line_len); + let paint_start = sel_start.max(line_start); + let paint_end = sel_end.min(line_end); + if paint_start >= paint_end { + continue; + } + + let Some(start_coord) = window.text_view.pos_to_display(buf, paint_start) else { + continue; + }; + let Some(end_coord) = window.text_view.pos_to_display(buf, paint_end) else { + continue; + }; + if start_coord.row as usize != display_row || end_coord.row as usize != display_row { + continue; + } + + let row_offset = display_row.saturating_sub(first_row) as u32; + let start_col = start_coord.col.min(rect.size.cols); + let end_col = end_coord.col.min(rect.size.cols); + if start_col >= end_col { + continue; + } + for col in start_col..end_col { + let cell = grid.at(CellCoord::new( + rect.origin.row + row_offset, + rect.origin.col + col, + )); + cell.style.reverse = true; + } + } +} + #[allow( clippy::too_many_arguments, reason = "the mode line packs eight unrelated facts; bundling them into a struct just adds ceremony" @@ -4055,6 +4161,94 @@ mod tests { assert!(core.active_region().is_none()); } + #[test] + fn mouse_drag_selection_paints_in_tui_grid() { + use crossterm::event::{MouseButton, MouseEventKind}; + let mut s = fresh_with(b"hello world\n"); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), 0, 0), + term_size_24x80(), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), 0, 5), + term_size_24x80(), + ); + + let (cells, _, _) = render_to_grid(&s, 24, 80); + for col in 0..5 { + let style = cells[col as usize].style; + assert!(style.reverse, "selected col {col} was not reverse video"); + } + assert!( + !cells[5].style.reverse, + "unselected cell after mouse selection was reverse video" + ); + } + + #[test] + fn mouse_double_click_selects_word_and_paints_in_tui_grid() { + use crossterm::event::{MouseButton, MouseEventKind}; + let mut s = fresh_with(b"hello world\n"); + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), 0, 7), + term_size_24x80(), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Up(MouseButton::Left), 0, 7), + term_size_24x80(), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), 0, 7), + term_size_24x80(), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Up(MouseButton::Left), 0, 7), + term_size_24x80(), + ); + + assert_eq!(s.core.borrow().cursor(), 11); + assert_eq!(s.core.borrow().active_region(), Some((6, 11))); + + let (cells, _, _) = render_to_grid(&s, 24, 80); + assert!(!cells[5].style.reverse, "selection leaked into separator"); + for col in 6..11 { + assert!( + cells[col as usize].style.reverse, + "double-click selected word missing col {col}" + ); + } + assert!(!cells[11].style.reverse, "selection leaked past word"); + } + + #[test] + fn mouse_double_click_on_separator_leaves_no_region() { + use crossterm::event::{MouseButton, MouseEventKind}; + let mut s = fresh_with(b"hello world\n"); + + for _ in 0..2 { + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), 0, 5), + term_size_24x80(), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Up(MouseButton::Left), 0, 5), + term_size_24x80(), + ); + } + + assert_eq!(s.core.borrow().cursor(), 5); + 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. @@ -4317,6 +4511,43 @@ mod tests { assert_eq!(s.core.borrow().cursor(), 14); } + #[test] + fn shift_arrow_extends_selection_and_paints_in_tui_grid() { + let mut s = fresh_with(b"abcdef\n"); + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT)); + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT)); + + assert_eq!(s.core.borrow().cursor(), 2); + assert_eq!(s.core.borrow().active_region(), Some((0, 2))); + + let (cells, stride, _) = render_to_grid(&s, 24, 80); + assert!(cells[0].style.reverse, "selection did not paint col 0"); + assert!(cells[1].style.reverse, "selection did not paint col 1"); + assert!(!cells[2].style.reverse, "selection leaked into col 2"); + assert_eq!(glyph_at(&cells, stride, 0, 0), 'a'); + assert_eq!(glyph_at(&cells, stride, 0, 1), 'b'); + } + + #[test] + fn ctrl_shift_arrow_extends_selection_by_words_and_paragraphs() { + let mut s = fresh_with(b"alpha beta\n\nsecond\n"); + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Right, KeyModifiers::CONTROL | KeyModifiers::SHIFT), + ); + assert_eq!(s.core.borrow().cursor(), 5); + assert_eq!(s.core.borrow().active_region(), Some((0, 5))); + + s.core.borrow_mut().active_window_mut().cursor = 0; + s.core.borrow_mut().clear_selection(); + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT), + ); + assert_eq!(s.core.borrow().cursor(), 11); + assert_eq!(s.core.borrow().active_region(), Some((0, 11))); + } + #[test] fn page_down_advances_cursor_and_view_top() { let mut content = Vec::new(); diff --git a/src/editor_core.rs b/src/editor_core.rs index af54432..24798ca 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -757,6 +757,28 @@ impl EditorCore { aw.goal_col = None; } + /// Select the word at the active cursor. Returns `false` when the + /// cursor is not on a word character. + pub fn select_word_at_cursor(&mut self) -> bool { + let id = self.active_buffer_id(); + let cursor = self.active_window().cursor; + let range = { + let reg = self.registry.borrow(); + let Ok(buffer) = reg.get(id) else { + return false; + }; + word_range_at(buffer, cursor) + }; + let Some((start, end)) = range else { + return false; + }; + let aw = self.active_window_mut(); + aw.selection = Some(crate::window::Selection { anchor: start }); + aw.cursor = end; + aw.goal_col = None; + true + } + /// Move the cursor forward to the next paragraph break. /// /// A paragraph break is a blank line (empty or whitespace-only). @@ -1383,6 +1405,16 @@ fn forward_word(buf: &Buffer, mut pos: Position) -> Position { pos } +fn word_range_at(buf: &Buffer, pos: Position) -> Option<(Position, Position)> { + let (ch, _) = char_at(buf, pos)?; + if !is_word_char(ch) { + return None; + } + let start = backward_word(buf, pos); + let end = forward_word(buf, pos); + (start < end).then_some((start, end)) +} + /// True iff `line` is empty or contains only ASCII whitespace. /// Used by paragraph motion: a blank line is a paragraph break. fn line_is_blank(buf: &Buffer, view: &TextView, line: usize) -> bool { diff --git a/src/lsp.rs b/src/lsp.rs index a8de7e2..e777c98 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -3044,24 +3044,7 @@ impl LspManager { let uri = uri.into(); let text = text.into(); self.documents.insert((sid, uri.clone()), text.clone()); - // T M11.8 / Session 8 — mark cached LSP-derived render - // families stale so semantic frontends suppress byte ranges - // anchored to pre-edit text until the server refreshes them. - // Diagnostics clear on `publishDiagnostics`, semantic tokens - // on `textDocument/semanticTokens`, and inlay hints on - // `textDocument/inlayHint`. - self.diag_store - .lock() - .expect("diag store mutex poisoned") - .mark_stale(uri.clone()); - self.semantic_token_store - .lock() - .expect("semantic token store mutex poisoned") - .mark_stale(uri.clone()); - self.inlay_hint_store - .lock() - .expect("inlay hint store mutex poisoned") - .mark_stale(uri.clone()); + self.mark_document_stale(&uri); let params = json!({ "textDocument": { "uri": uri, @@ -3074,6 +3057,32 @@ impl LspManager { self.send_notification(sid, "textDocument/didChange", params) } + /// T M11.8 / Session 8 — mark cached LSP-derived render families + /// for `uri` stale so frontends suppress byte ranges anchored to + /// pre-edit text until the server refreshes them. Diagnostics + /// clear on `publishDiagnostics`, semantic tokens on + /// `textDocument/semanticTokens`, and inlay hints on + /// `textDocument/inlayHint`. + /// + /// Factored out of [`Self::did_change_full`] so the Lua glue can + /// mark staleness at *edit* time even while the (full-document, + /// O(file)) didChange notification itself is debounced — per-edit + /// staleness is what keeps stale-position artifacts off screen. + pub fn mark_document_stale(&self, uri: &str) { + self.diag_store + .lock() + .expect("diag store mutex poisoned") + .mark_stale(uri.to_owned()); + self.semantic_token_store + .lock() + .expect("semantic token store mutex poisoned") + .mark_stale(uri.to_owned()); + self.inlay_hint_store + .lock() + .expect("inlay hint store mutex poisoned") + .mark_stale(uri.to_owned()); + } + /// Send `workspace/didChangeWatchedFiles` to `sid`. `changes` is /// the already-shaped `FileEvent[]` array (`[{ uri, type }]`, /// type 1=created / 2=changed / 3=deleted) the Lua file-watch diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 334ef3f..33afe52 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -7261,6 +7261,23 @@ pub fn install_lsp( )?; } + { + // Mark `uri`'s cached LSP render families (diagnostics, + // semantic tokens, inlay hints) stale without sending + // anything. The didChange-debounce glue in + // `builtin/runtime/lsp.lua` calls this per edit so stale + // suppression stays keystroke-accurate while the O(file) + // full-document notification is coalesced. + let m = manager.clone(); + lsp_mod.set( + "_mark_document_stale", + lua.create_function(move |_, uri: String| { + m.borrow().mark_document_stale(&uri); + Ok(()) + })?, + )?; + } + { let m = manager.clone(); lsp_mod.set( @@ -11428,6 +11445,22 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result })?, )?; } + { + // Milliseconds on a process-local monotonic clock. Only + // differences are meaningful (the epoch is the first call). + // Exists for Lua-side debounce/throttle logic — notably the + // LSP didChange coalescing in `builtin/runtime/lsp.lua` — + // which needs wall-clock-independent elapsed time; `os.clock` + // is CPU time and `os.time` is second-granular. + editor.set( + "monotonic_ms", + lua.create_function(|_, ()| { + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + let epoch = *EPOCH.get_or_init(std::time::Instant::now); + Ok(i64::try_from(epoch.elapsed().as_millis()).unwrap_or(i64::MAX)) + })?, + )?; + } { let cc = core.clone(); editor.set( diff --git a/src/process.rs b/src/process.rs index f990711..307d56c 100644 --- a/src/process.rs +++ b/src/process.rs @@ -51,7 +51,7 @@ use std::collections::HashMap; use std::io::{Read, Write}; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -427,7 +427,7 @@ struct ManagedProcess { /// when the generation ends. struct RuntimeHandles { child: ChildHandle, - stdin: Option>, + stdin: Option, pid: u32, /// Reader-thread join handles, drained by `Drop` of /// [`RuntimeHandles`] so a generation's worker threads don't @@ -444,6 +444,103 @@ struct RuntimeHandles { cancel: Arc, } +/// Byte budget for stdin data queued but not yet written, per +/// generation. A child this far behind on reading its own stdin is +/// effectively not consuming it; erroring beats unbounded queue +/// growth, and callers already treat `write_stdin` errors as +/// process failure. Generous so it never triggers for a merely-busy +/// child (LSP full-document didChange on a large file is ~MB-scale). +const STDIN_QUEUE_MAX_BYTES: usize = 64 * 1024 * 1024; + +/// Queued stdin writer: a dedicated thread owns the child's stdin +/// handle and drains a channel of byte chunks. This decouples +/// callers — the editor main thread, notably the LSP manager's +/// full-document `didChange` notifications — from pipe +/// backpressure: a child that stops reading (kernel pipe buffers +/// are ~64 KiB) stalls this queue, not the editor frame loop. +/// +/// Closing: dropping the sender (`close_stdin` / generation end) +/// lets the thread drain whatever is queued, then drop the handle — +/// the child sees EOF *after* the queued bytes, preserving the +/// flush-then-EOF shutdown contract MCP relies on. The thread is +/// detached rather than joined: joining at drop could block forever +/// on a wedged pipe, and generation teardown (SIGTERM/SIGKILL) +/// breaks the pipe and ends the thread shortly after anyway. +struct StdinWriter { + tx: Sender>, + /// Bytes accepted by [`Self::write`] but not yet written by the + /// thread. Backpressure signal for the queue budget. + queued_bytes: Arc, + /// First write error observed by the writer thread. Writes are + /// asynchronous, so the failure surfaces on the *next* `write` + /// call instead of the one that hit it. + error: Arc>>, +} + +impl StdinWriter { + fn spawn(mut sink: Box) -> Self { + let (tx, rx) = channel::unbounded::>(); + let queued_bytes = Arc::new(AtomicUsize::new(0)); + let error = Arc::new(Mutex::new(None)); + let thread_queued = Arc::clone(&queued_bytes); + let thread_error = Arc::clone(&error); + std::thread::Builder::new() + .name("pmacs stdin writer".into()) + .spawn(move || { + while let Ok(bytes) = rx.recv() { + let result = sink.write_all(&bytes).and_then(|()| sink.flush()); + thread_queued.fetch_sub(bytes.len(), Ordering::Relaxed); + if let Err(e) = result { + *thread_error + .lock() + .expect("stdin writer error mutex poisoned") = Some(e.to_string()); + return; + } + } + // Channel closed: all queued chunks written. `sink` + // drops here, closing the pipe — the child sees EOF. + }) + .expect("spawn stdin writer thread"); + Self { + tx, + queued_bytes, + error, + } + } + + fn write(&self, bytes: &[u8]) -> Result<(), String> { + if let Some(e) = self + .error + .lock() + .expect("stdin writer error mutex poisoned") + .as_ref() + { + return Err(format!("write_stdin: {e}")); + } + let queued = self.queued_bytes.load(Ordering::Relaxed); + if queued.saturating_add(bytes.len()) > STDIN_QUEUE_MAX_BYTES { + return Err(format!( + "write_stdin: child is not draining stdin ({queued} bytes already queued)" + )); + } + self.queued_bytes.fetch_add(bytes.len(), Ordering::Relaxed); + self.tx.send(bytes.to_vec()).map_err(|_| { + // Thread exited after a write error; report the stored + // cause when we have it. + self.queued_bytes.fetch_sub(bytes.len(), Ordering::Relaxed); + let stored = self + .error + .lock() + .expect("stdin writer error mutex poisoned") + .clone(); + stored.map_or_else( + || "write_stdin: writer thread stopped".to_owned(), + |e| format!("write_stdin: {e}"), + ) + }) + } +} + impl Drop for RuntimeHandles { fn drop(&mut self) { // Wake any reader thread blocked in a bounded `send` --- @@ -723,12 +820,13 @@ impl ProcessSupervisor { self.signal(id, Signal::SIGTERM) } - /// Close `id`'s stdin pipe by dropping the writer. The child - /// observes EOF on its next read, which is the canonical - /// stdio-graceful-shutdown signal for protocols (notably MCP) - /// that have no protocol-level shutdown message. Idempotent: a - /// second call after the writer is gone is a no-op. Errors only - /// if the process id is unknown. + /// Close `id`'s stdin pipe by dropping the writer. The writer + /// thread drains any queued bytes first, then drops the handle, + /// so the child observes EOF *after* everything already written + /// — the canonical stdio-graceful-shutdown signal for protocols + /// (notably MCP) that have no protocol-level shutdown message. + /// Idempotent: a second call after the writer is gone is a + /// no-op. Errors only if the process id is unknown. /// /// Note: this does NOT kill the process. Callers that want a /// guaranteed exit follow up with [`Self::terminate`] (SIGTERM) @@ -749,10 +847,14 @@ impl ProcessSupervisor { } /// Write `bytes` to `id`'s stdin. Errors if the id is unknown, - /// the process is not running, or stdin is closed (the child + /// the process is not running, stdin is closed (the child /// closed stdin on its end, or stdin was never piped in the - /// first place). Synchronous write --- callers that worry about - /// pipe-full blocking should chunk their writes. + /// first place), or the per-generation queue budget is + /// exhausted. The write itself is queued to a dedicated writer + /// thread, so this never blocks on pipe backpressure — a write + /// *failure* (broken pipe) therefore surfaces on a subsequent + /// call rather than the one that queued the bytes; callers that + /// need liveness should watch the supervisor's exit events. pub fn write_stdin(&mut self, id: ProcessId, bytes: &[u8]) -> Result<(), String> { let proc = self .processes @@ -764,13 +866,9 @@ impl ProcessSupervisor { .ok_or_else(|| format!("process {id} has no live generation"))?; let stdin = runtime .stdin - .as_mut() + .as_ref() .ok_or_else(|| format!("process {id} stdin is not piped"))?; - stdin - .write_all(bytes) - .map_err(|e| format!("write_stdin: {e}"))?; - stdin.flush().map_err(|e| format!("flush_stdin: {e}"))?; - Ok(()) + stdin.write(bytes) } /// Resize the PTY for `id`. Errors if the id is unknown, the @@ -1128,7 +1226,7 @@ fn build_pipes_runtime(spec: &ProcessSpec, _id: ProcessId) -> Result); + .map(|s| StdinWriter::spawn(Box::new(s) as Box)); let stdout = child.stdout.take(); let stderr = child.stderr.take(); let (byte_tx, byte_rx) = channel::bounded::(BYTE_CHUNK_CHANNEL_CAP); @@ -1238,7 +1336,7 @@ fn build_pty_runtime( child: Arc::new(Mutex::new(into_send_sync_child(child))), _master: pair.master, }, - stdin: Some(writer), + stdin: Some(StdinWriter::spawn(writer)), pid, readers, output_rx, @@ -1606,6 +1704,82 @@ mod tests { ); } + #[test] + fn write_stdin_queues_without_blocking_when_child_never_reads() { + let mut sup = ProcessSupervisor::new(); + // The child never reads its stdin, so the kernel pipe buffer + // (~64 KiB) fills almost immediately. The pre-writer-thread + // implementation blocked the caller in `write_all` here — + // which in the editor was the main thread, wedging the frame + // loop whenever an LSP server fell behind on its stdin. + let mut spec = ProcessSpec::new("stdin-ignorer", "/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + let id = sup.spawn(spec).expect("spawn"); + let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { + evs.iter() + .any(|e| matches!(e.kind, ProcessEventKind::Started { .. })) + }); + let payload = vec![b'x'; 1024 * 1024]; // 16x the pipe buffer + let start = Instant::now(); + sup.write_stdin(id, &payload).expect("queued write"); + assert!( + start.elapsed() < Duration::from_secs(2), + "write_stdin must queue, not block on pipe backpressure (took {:?})", + start.elapsed() + ); + sup.terminate(id).expect("terminate"); + let _ = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); + } + + #[test] + fn close_stdin_flushes_queued_bytes_before_eof() { + let mut sup = ProcessSupervisor::new(); + // `cat` echoes stdin and exits on EOF. Receiving the full + // payload back followed by a clean exit proves the writer + // thread drains its queue before dropping the pipe (the + // flush-then-EOF contract `close_stdin` documents). + let mut spec = ProcessSpec::new("cat-echo", "/bin/sh"); + spec.args = vec!["-c".into(), "cat".into()]; + let id = sup.spawn(spec).expect("spawn"); + let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { + evs.iter() + .any(|e| matches!(e.kind, ProcessEventKind::Started { .. })) + }); + let payload = vec![b'y'; 256 * 1024]; + sup.write_stdin(id, &payload).expect("queued write"); + sup.close_stdin(id).expect("close stdin"); + let evs = drain_until(&mut sup, id, Duration::from_secs(10), |evs| { + let echoed: usize = evs + .iter() + .filter_map(|e| match &e.kind { + ProcessEventKind::Stdout(b) => Some(b.len()), + _ => None, + }) + .sum(); + echoed >= 256 * 1024 + && evs + .iter() + .any(|e| matches!(e.kind, ProcessEventKind::Exited { .. })) + }); + let echoed: usize = evs + .iter() + .filter_map(|e| match &e.kind { + ProcessEventKind::Stdout(b) => Some(b.len()), + _ => None, + }) + .sum(); + assert_eq!( + echoed, + payload.len(), + "child must receive every queued byte before EOF" + ); + assert!( + evs.iter() + .any(|e| matches!(e.kind, ProcessEventKind::Exited { code: 0 })), + "EOF after drain must let the child exit cleanly" + ); + } + #[test] fn restart_on_crash_respawns_after_nonzero_exit() { let mut sup = ProcessSupervisor::new(); diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 7b065d5..22ef343 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -124,6 +124,21 @@ pub struct SemanticRenderState { /// Only the grammar (tree-sitter) path is gated; the LSP-token path /// has no comparably cheap handle and recomputes as before. last_style_gate: HashMap, + /// Cached byte↔line table for the diagnostics projection, keyed + /// by buffer revision. Building it costs an O(buffer) rope copy + /// plus a full scan; before this cache, that ran on *every tick* + /// while diagnostics were on screen (the table is only consulted + /// when the store is non-stale and non-empty) — a steady-state + /// CPU burn for a value that changes only when the buffer does. + diag_line_cache: HashMap, +} + +/// One [`SemanticRenderState::diag_line_cache`] entry: the line-start +/// offsets and source length of a buffer at `revision`. +struct DiagLineCache { + revision: u64, + line_starts: Vec, + source_len: u64, } /// Recompute gate for [`scoped_style_spans`] on a grammar-backed @@ -169,6 +184,7 @@ impl SemanticRenderState { last_adornments: HashMap::new(), last_summary: HashMap::new(), last_style_gate: HashMap::new(), + diag_line_cache: HashMap::new(), } } @@ -220,14 +236,29 @@ impl SemanticRenderState { // already-sent baseline — can skip the whole block. The LSP- // token path returns `None` (no cheap revision) and recomputes // every tick as before. - let style_gate = grammar_style_key(state, &vp, generation); + let style_parse_not_ready = grammar_style_parse_not_ready(state, vp.buffer_id); + // The LSP-token styling authority (grammar-less buffers, e.g. + // C++) gets the same hold: while the semantic-token store is + // stale (document edited since the last token response), + // `lsp_scoped_style_spans` would compute an empty set, and + // shipping that clears the frontend's colors for the whole + // stale window — the styling twin of the diagnostics blink. + let style_tokens_stale = lsp_style_tokens_stale(state, vp.buffer_id); + let style_hold = style_parse_not_ready || style_tokens_stale; + let style_gate = (!style_hold).then(|| grammar_style_key(state, &vp, generation)); + let style_gate = style_gate.flatten(); let style_unchanged = match (&style_gate, self.last_style_gate.get(&vp.buffer_id)) { (Some(g), Some(prev)) => g.matches(prev) && self.last_sent.contains_key(&vp.buffer_id), _ => false, }; - if style_unchanged { - // Styling cannot have changed since the last computation; - // emit nothing and skip the query. + if style_hold || style_unchanged { + // If the style key is unchanged, styling cannot have + // changed since the last computation. If a grammar parse is + // still pending (or the LSP token store is stale), keep the + // previous spans briefly rather than querying and reshaping + // stale syntax on every typed byte; the parse-bundle + // revision (or the next token response) will force a fresh + // frame as soon as it settles. } else { match style_gate { Some(g) => { @@ -243,8 +274,36 @@ impl SemanticRenderState { // --- Decorations (T M11.3 producer, T M11.4 diff) --- let decorations = self.scoped_decorations(state, &vp); let prev = self.last_decorations.get(&vp.buffer_id); + // Hold-while-stale: while the diag store is stale (document + // edited since the last `publishDiagnostics`), this frame has + // no authoritative diagnostic positions. The frontend's + // last-received set — which it translates through its own + // local edits — is strictly better than anything we can ship: + // an empty frame wipes it (diagnostics blink out on the first + // keystroke of every burst and back in after the next publish, + // one full frontend reshape each way), and re-shipping the + // store's items would anchor pre-edit positions over post-edit + // text (the M11.8 artifact). So as long as the + // *non-diagnostic* part is unchanged, say nothing and leave + // the baseline untouched — staleness clears on the next + // publishDiagnostics absorption, and the generation transition + // since the held baseline forces that frame full. + let held = diagnostics_store_stale(state, vp.buffer_id) + && prev.is_some_and(|p| { + decorations + .iter() + .eq(p.items.iter().filter(|d| !is_diagnostic_kind(d.kind))) + }); let full = prev.is_none_or(|p| p.visible != vp.visible || p.generation != generation); - if full { + if held { + // No new information for the frontend this frame. (A + // selection change during the stale window falls through + // to the branches below and ships without diagnostics — + // rare, and better than pinning a dead selection.) + } else if full { + let suppress_empty_generation_bump = prev.is_some_and(|p| { + p.visible == vp.visible && p.items.is_empty() && decorations.is_empty() + }); self.last_decorations.insert( vp.buffer_id, LastFrame { @@ -253,15 +312,17 @@ impl SemanticRenderState { generation, }, ); - out.push(InstanceMessage::Decorations { - buffer_id: vp.buffer_id, - generation, - full: true, - segments: vec![DecorationSegment { - range: vp.visible, - decorations, - }], - }); + if !suppress_empty_generation_bump { + out.push(InstanceMessage::Decorations { + buffer_id: vp.buffer_id, + generation, + full: true, + segments: vec![DecorationSegment { + range: vp.visible, + decorations, + }], + }); + } } else { let prev = prev.expect("checked is_none_or above"); let intervals = changed_intervals(&prev.items, &decorations, |d| d.range); @@ -308,6 +369,17 @@ impl SemanticRenderState { state: &EditorState, vp: &DeclaredViewport, ) -> Option { + // Hold-while-stale — mirrors the Decorations hold in + // `render_frame`. An empty frame here wipes the frontend's + // cached virtual text mid-typing-burst, and inline adornments + // occupy layout space: the wipe visibly shifts real glyphs + // (and forces a reshape), then the post-refresh re-emit + // shifts them back. The frontend's locally-translated cache + // is the better picture until a fresh `inlayHint` response + // clears the stale flag and re-emits through the diff below. + if inlay_store_stale(state, vp.buffer_id) { + return None; + } let adornments = scoped_inline_adornments(state, vp); let should_emit = match self.last_adornments.get(&vp.buffer_id) { // First sight of this buffer: speak only if there is @@ -356,6 +428,18 @@ impl SemanticRenderState { buffer_id: BufferId, generation: u64, ) -> Option { + // The summary is a *whole-file* tree-sitter pass (the minimap + // needs every line). Recomputing it on every edit's generation + // bump was a per-keystroke O(file) cost — a major part of the + // typing slowness. For grammar-backed buffers, debounce it to + // reparse-completion. `pending_edit_count()` alone is not + // enough: dispatch drains that list immediately, leaving the + // expensive summary path free to run while a parse job is still + // in flight. Wait until there is an installed parse, no pending + // edits, and no recorded parse job for this buffer. + if grammar_style_parse_not_ready(state, buffer_id) { + return None; + } if self.last_summary.get(&buffer_id).copied() == Some(generation) { return None; } @@ -444,56 +528,31 @@ impl SemanticRenderState { } } - fn scoped_decorations(&self, state: &EditorState, vp: &DeclaredViewport) -> Vec { + fn scoped_decorations( + &mut self, + state: &EditorState, + vp: &DeclaredViewport, + ) -> Vec { let core = state.core.borrow(); let registry = core.registry.clone(); let reg = registry.borrow(); let mut out = Vec::new(); - // Byte<->line mapping is needed by both the CurrentLine - // derivation and the diagnostics projection, and - // `buffer_source_bytes` is an O(n) rope copy. This runs every - // tick in the daemon's hot loop, so materialize at most once - // per call and reuse — never twice (the pre-9.2 shape copied - // separately in each branch). - let mut line_info: Option<(Vec, Vec)> = None; - - // Selection + CurrentLine — per-window (per-frontend) state. - // Only this session's active window for the declared buffer - // contributes either kind. - // - // Q#3 (per-line CurrentLine cadence, stance β) falls out of the - // existing M11.4 diff: `render_frame` compares the new - // decoration Vec against the last sent one and emits only on - // change. Horizontal cursor motion within a single line - // produces an identical `CurrentLine` range and an identical - // overall Vec, so `changed_intervals` returns empty and nothing - // ships. No `last_cursor_line` cache is needed at this layer. + // Selection is per-window (per-frontend) state. CurrentLine is + // deliberately not emitted for semantic frontends: the GPU has + // CursorByte and paints its own caret/current-line affordances. + // Emitting CurrentLine here forced a whole-buffer line table on + // every frame even though pmacs-gpu ignores its own current-line + // wash. if let Some(win) = core.active_window_for(self.frontend_id) && win.buffer_id == vp.buffer_id + && let Some((lo, hi)) = win.region() + && let Some(range) = clip_to_viewport(lo, hi, vp) { - if let Some((lo, hi)) = win.region() - && let Some(range) = clip_to_viewport(lo, hi, vp) - { - out.push(Decoration { - range, - kind: DecorationKind::Selection, - }); - } - if let Ok(buf) = reg.get(vp.buffer_id) { - let (source, line_starts) = line_info.get_or_insert_with(|| { - let s = buffer_source_bytes(buf); - let ls = line_start_offsets(&s); - (s, ls) - }); - let (lo, hi) = current_line_range(line_starts, source.len() as u64, win.cursor); - if let Some(range) = clip_to_viewport(lo, hi, vp) { - out.push(Decoration { - range, - kind: DecorationKind::CurrentLine, - }); - } - } + out.push(Decoration { + range, + kind: DecorationKind::Selection, + }); } // Diagnostics — keyed in the shared store by the file URI the @@ -518,12 +577,30 @@ impl SemanticRenderState { && !diags.is_empty() && let Ok(buf) = reg.get(vp.buffer_id) { - let (source, line_starts) = line_info.get_or_insert_with(|| { - let s = buffer_source_bytes(buf); - let ls = line_start_offsets(&s); - (s, ls) - }); - let source_len = source.len() as u64; + // Byte<->line mapping, cached per buffer revision — + // rebuilding it is an O(buffer) rope copy + scan, far + // too expensive to repeat on every tick a diagnostic + // is on screen. + let cache = self + .diag_line_cache + .entry(vp.buffer_id) + .and_modify(|c| { + if c.revision != buf.revision() { + let s = buffer_source_bytes(buf); + c.revision = buf.revision(); + c.line_starts = line_start_offsets(&s); + c.source_len = s.len() as u64; + } + }) + .or_insert_with(|| { + let s = buffer_source_bytes(buf); + DiagLineCache { + revision: buf.revision(), + line_starts: line_start_offsets(&s), + source_len: s.len() as u64, + } + }); + let (line_starts, source_len) = (&cache.line_starts, cache.source_len); for d in &diags { let lo = line_col_to_byte(line_starts, source_len, d.start_line, d.start_col); let hi = line_col_to_byte(line_starts, source_len, d.end_line, d.end_col); @@ -711,6 +788,61 @@ fn clip_decorations(iv: ByteRange, decos: &[Decoration]) -> Vec { .collect() } +/// True for the four diagnostic-underline decoration kinds — the +/// family whose emission is gated on diag-store staleness by the +/// hold-while-stale logic in `render_frame`. +fn is_diagnostic_kind(kind: DecorationKind) -> bool { + matches!( + kind, + DecorationKind::DiagnosticError + | DecorationKind::DiagnosticWarning + | DecorationKind::DiagnosticInfo + | DecorationKind::DiagnosticHint + ) +} + +/// True when `buffer_id`'s entry in the diagnostics store is stale +/// (the document changed since the last `publishDiagnostics` +/// absorption). Buffers with no file URI are never stale. +fn diagnostics_store_stale(state: &EditorState, buffer_id: BufferId) -> bool { + let core = state.core.borrow(); + let Some(uri) = buffer_file_uri(&core, buffer_id) else { + return false; + }; + let store = state.lsp_manager.borrow().diag_store(); + let guard = store.lock().expect("diag store mutex poisoned"); + guard.is_stale(&uri) +} + +/// Style-family staleness for the LSP-token authority. True only for +/// a buffer with **no** tree-sitter view (policy A routes those +/// through `lsp_scoped_style_spans`) whose semantic-token store entry +/// is stale. Grammar-backed buffers always return `false` — their +/// styling freshness is `grammar_style_parse_not_ready`'s job. +fn lsp_style_tokens_stale(state: &EditorState, buffer_id: BufferId) -> bool { + if state.syntax_registry.view(buffer_id).is_some() { + return false; + } + let core = state.core.borrow(); + let Some(uri) = buffer_file_uri(&core, buffer_id) else { + return false; + }; + let store = state.lsp_manager.borrow().semantic_token_store(); + let guard = store.lock().expect("semantic token store mutex poisoned"); + guard.is_stale(&uri) +} + +/// Inlay-hint twin of [`diagnostics_store_stale`]. +fn inlay_store_stale(state: &EditorState, buffer_id: BufferId) -> bool { + let core = state.core.borrow(); + let Some(uri) = buffer_file_uri(&core, buffer_id) else { + return false; + }; + let store = state.lsp_manager.borrow().inlay_hint_store(); + let guard = store.lock().expect("inlay-hint store mutex poisoned"); + guard.is_stale(&uri) +} + /// Map an LSP diagnostic severity onto the wire decoration kind. fn severity_to_kind(sev: crate::diag::DiagnosticSeverity) -> DecorationKind { use crate::diag::DiagnosticSeverity as S; @@ -758,31 +890,6 @@ fn buffer_source_bytes(buf: &crate::buffer::Buffer) -> Vec { bytes } -/// Byte range `(start, end)` of the line containing `cursor`, where -/// `start` is the position right after the previous `\n` (or 0 for the -/// first line) and `end` is the position of the next `\n` (or -/// `source_len` for the last line). Used by `scoped_decorations` to -/// emit `DecorationKind::CurrentLine`; clamps so a cursor at or past -/// `source_len` returns the last line's range rather than indexing -/// out. -fn current_line_range(line_starts: &[u64], source_len: u64, cursor: u64) -> (u64, u64) { - // `partition_point` returns the count of leading elements satisfying - // the predicate, i.e. the index of the first `line_start > cursor`. - // Subtracting 1 yields the index of the largest `line_start <= - // cursor`. `line_starts` always starts with 0, so the saturating - // sub is defensive against an empty `line_starts`. - let idx = line_starts - .partition_point(|&start| start <= cursor) - .saturating_sub(1); - let lo = line_starts.get(idx).copied().unwrap_or(0); - let hi = line_starts - .get(idx + 1) - .copied() - .unwrap_or(source_len) - .min(source_len); - (lo, hi) -} - /// Byte offset of the start of each line (index 0 = byte 0; one entry /// per line, where a line is a maximal run ended by `\n`). fn line_start_offsets(source: &[u8]) -> Vec { @@ -830,6 +937,15 @@ fn grammar_style_key( }) } +fn grammar_style_parse_not_ready(state: &EditorState, buffer_id: BufferId) -> bool { + let Some(handle) = state.syntax_registry.view(buffer_id) else { + return false; + }; + handle.current().is_none() + || handle.pending_edit_count() > 0 + || state.syntax_registry.has_pending_parse_job_for(buffer_id) +} + /// Compute the styled byte runs intersecting the declared viewport, /// mapped through the active theme. Spans are clipped to the viewport /// and to the parsed source length; runs that resolve to the default @@ -871,7 +987,15 @@ fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec crate::syntax::ParseViewHandle { + let language = state + .syntax_registry + .language("rust") + .expect("rust language"); + let mut core = state.core.borrow_mut(); + let registry_handle = core.registry.clone(); + let mut registry = registry_handle.borrow_mut(); + let buf = registry.get_mut(buffer_id).expect("active buffer"); + if !text.is_empty() { + buf.apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: text, + }) + .expect("seed rust text"); + } + let parse_view = crate::syntax::ParseView::new(buf, language, "rust".to_owned()); + let handle = parse_view.handle(); + let req = handle.make_request(); + let bundle = crate::syntax::run_parse(req).expect("initial rust parse"); + handle.install(std::sync::Arc::new(bundle)); + buf.attach_view(Box::new(parse_view)); + drop(registry); + core.set_buffer_path(buffer_id, Some(std::path::PathBuf::from("/tmp/x.rs"))); + drop(core); + state.syntax_registry.attach_view(buffer_id, handle.clone()); + handle + } + #[test] fn cpp_style_comes_from_lsp_when_no_tree_sitter_grammar() { let state = empty_state(); @@ -1933,6 +2139,123 @@ mod tests { ); } + #[test] + fn grammar_style_spans_wait_for_pending_parse() { + let state = empty_state(); + let mut s = local(); + let bid = active_buffer(&state); + let handle = seed_rust_parse_view(&state, bid, b"fn main() {}\n"); + s.set_viewport( + bid, + ByteRange { + start: 0, + end: 4096, + }, + 0, + ); + + let first = s.render_frame(&state); + assert!( + style_segments(&first).is_some(), + "installed parse emits the baseline style frame" + ); + + { + let core = state.core.borrow(); + core.registry + .borrow_mut() + .get_mut(bid) + .expect("active buffer") + .apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: b"// editing\n", + }) + .expect("typing edit"); + } + assert!( + handle.pending_edit_count() > 0, + "attached parse view recorded the edit" + ); + let pending = s.render_frame(&state); + assert!( + style_segments(&pending).is_none(), + "style query is skipped while edits are waiting for parse dispatch" + ); + + let req = handle.make_request(); + state.syntax_registry.record_parse_job(9001, bid); + let in_flight = s.render_frame(&state); + assert!( + style_segments(&in_flight).is_none(), + "style query is skipped while the parse job is in flight" + ); + + let bundle = crate::syntax::run_parse(req).expect("settled rust parse"); + handle.install(std::sync::Arc::new(bundle)); + assert_eq!(state.syntax_registry.take_parse_job(9001), Some(bid)); + let settled = s.render_frame(&state); + assert!( + style_segments(&settled).is_some(), + "new parse bundle emits refreshed style spans" + ); + } + + /// Hold-while-stale for the LSP-token styling authority: once a + /// grammar-less buffer's colors have shipped, marking the token + /// store stale (which happens per edit) must NOT ship a clearing + /// frame — the styling twin of the diagnostics hold. The next + /// token response clears the flag and re-emits. + #[test] + fn lsp_style_holds_while_token_store_stale() { + let state = empty_state(); + let mut s = local(); + let bid = active_buffer(&state); + let sid = seed_lsp_style(&state, bid, b"int x;\n", vec![tok(0, 0, 3)]); + s.set_viewport( + bid, + ByteRange { + start: 0, + end: 4096, + }, + 0, + ); + assert!( + style_segments(&s.render_frame(&state)).is_some(), + "baseline ships the LSP-token styling" + ); + + let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/x.cpp")); + { + let store = state.lsp_manager.borrow().semantic_token_store(); + let mut guard = store.lock().expect("semantic token store"); + guard.mark_stale(uri.clone()); + } + + assert!( + style_segments(&s.render_frame(&state)).is_none(), + "stale token store holds StyleSpans instead of clearing the colors" + ); + assert!( + style_segments(&s.render_frame(&state)).is_none(), + "the hold is stable across frames" + ); + + // A fresh token response (absorbed via `set`) clears the flag. + // Identical tokens produce no frame — the frontend's cache was + // never cleared, so there is nothing to say. Changed tokens + // diff against the held baseline and ship. + set_tokens(&state, sid, vec![tok(0, 0, 3)]); + assert!( + style_segments(&s.render_frame(&state)).is_none(), + "fresh-but-identical tokens stay silent (cache was never wiped)" + ); + set_tokens(&state, sid, vec![tok(0, 0, 5)]); + assert!( + style_segments(&s.render_frame(&state)).is_some(), + "fresh changed tokens re-emit the styling" + ); + } + #[test] fn lsp_style_suppressed_when_unchanged() { let state = empty_state(); @@ -2139,7 +2462,7 @@ mod tests { } #[test] - fn inline_adornments_emit_empty_clear_while_inlay_store_stale() { + fn inline_adornments_hold_while_inlay_store_stale() { let state = empty_state(); let mut s = local(); let bid = active_buffer(&state); @@ -2155,26 +2478,30 @@ mod tests { let store = state.lsp_manager.borrow().inlay_hint_store(); store.lock().expect("inlay store").mark_stale(uri.clone()); - let clear = - adornments_of(&s.render_frame(&state)).expect("stale transition clears adornments"); + // Hold-while-stale: no frame at all. The frontend keeps its + // last-received hints, translated through its own local + // edits — an empty frame here would wipe them and visibly + // shift the line layout on the first keystroke of a burst. assert!( - clear.is_empty(), - "stale hints must clear the frontend's cached virtual text" + adornments_of(&s.render_frame(&state)).is_none(), + "stale store holds emission (frontend keeps its translated cache)" ); assert!( adornments_of(&s.render_frame(&state)).is_none(), - "unchanged stale-empty state is suppressed after the clear" + "the hold is stable across frames" ); - set_inlay_store(&state, &uri, vec![hint(0, 5, ": i32")]); + // A fresh inlayHint response clears the flag and re-emits at + // the server's (possibly shifted) positions. + set_inlay_store(&state, &uri, vec![hint(0, 7, ": i32")]); let refreshed = adornments_of(&s.render_frame(&state)).expect("fresh hints re-emit"); assert_eq!(refreshed.len(), 1); - assert_eq!(refreshed[0].at, 5); + assert_eq!(refreshed[0].at, 7); } #[cfg(feature = "crdt")] #[test] - fn session8_temporal_probe_sustained_edits_clear_stale_inlays_until_refresh() { + fn session8_temporal_probe_sustained_edits_hold_stale_inlays_until_refresh() { let state = empty_state(); let mut s = local(); let bid = active_buffer(&state); @@ -2247,16 +2574,17 @@ mod tests { } assert_eq!( - clear_frames, 1, - "first stale frame clears cached hints; later stale frames stay silent" + clear_frames, 0, + "stale frames hold emission entirely — the frontend keeps \ + its locally-translated hints instead of blinking them out" ); assert_eq!( full_style_frames, 1000, "each CRDT generation transition forces a StyleSpans full resync" ); assert_eq!( - full_deco_frames, 1000, - "each CRDT generation transition forces a Decorations full resync" + full_deco_frames, 0, + "empty Decorations state stays silent across generation transitions" ); set_inlay_store(&state, &uri, vec![hint(0, 1005, ": i32")]); diff --git a/src/syntax.rs b/src/syntax.rs index 1f9264e..70106c5 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -579,6 +579,16 @@ impl SyntaxRegistry { self.parse_jobs.borrow().len() } + /// True when a dispatched parse job for `buffer` has not yet been + /// installed or drained. The syntax Lua glue records jobs here at + /// dispatch time and removes them in `_install_settled`, so this + /// is the main-thread "parse in flight" bit for render producers + /// that need to avoid stale whole-file work while typing. + #[must_use] + pub fn has_pending_parse_job_for(&self, buffer: BufferId) -> bool { + self.parse_jobs.borrow().values().any(|&bid| bid == buffer) + } + /// Lazy-compile and cache the bundled `highlights.scm` query for /// `lang_name`. Returns `None` if the language is unknown, the /// language entry has an empty query (no highlights shipped), @@ -691,9 +701,27 @@ pub struct HighlightSpan { pub fn compute_highlight_spans( query: &tree_sitter::Query, bundle: &ParseTreeBundle, +) -> Vec { + compute_highlight_spans_in_range(query, bundle, None) +} + +/// Like [`compute_highlight_spans`], but restricts the query to nodes +/// intersecting `byte_range` when `Some`. tree-sitter's +/// `QueryCursor::set_byte_range` makes the capture walk proportional to +/// the range, not the whole tree — the semantic producer passes the +/// declared viewport so styling a screenful of a huge file is +/// O(visible), not O(file) (the per-edit typing cost; framing Q#S6). +#[must_use] +pub fn compute_highlight_spans_in_range( + query: &tree_sitter::Query, + bundle: &ParseTreeBundle, + byte_range: Option>, ) -> Vec { let mut spans = Vec::new(); let mut cursor = tree_sitter::QueryCursor::new(); + if let Some(range) = byte_range { + cursor.set_byte_range(range); + } let source: &[u8] = bundle.source.as_ref(); let root = bundle.tree.root_node(); let mut iter = cursor.captures(query, root, source); @@ -851,4 +879,21 @@ mod tests { // Pending list cleared on make_request. assert_eq!(handle.pending_edit_count(), 0); } + + #[test] + fn registry_tracks_inflight_parse_jobs_by_buffer() { + let registry = SyntaxRegistry::new(); + let a = BufferId::next(); + let b = BufferId::next(); + + registry.record_parse_job(11, a); + registry.record_parse_job(12, b); + + assert!(registry.has_pending_parse_job_for(a)); + assert!(registry.has_pending_parse_job_for(b)); + + assert_eq!(registry.take_parse_job(11), Some(a)); + assert!(!registry.has_pending_parse_job_for(a)); + assert!(registry.has_pending_parse_job_for(b)); + } } diff --git a/src/text_view.rs b/src/text_view.rs index 9549031..b552ae4 100644 --- a/src/text_view.rs +++ b/src/text_view.rs @@ -34,6 +34,10 @@ use crate::view::{DisplayCoord, View, Viewport}; /// that is a multiple of this value. const TAB_WIDTH: u32 = 8; +/// Line-prefix lengths up to this many bytes are decoded on the stack in +/// [`TextView::pos_to_display`]; longer prefixes fall back to a heap buffer. +const STACK_CAP: usize = 256; + /// Display width of `ch` when drawn starting at column `current_col`. /// /// Tabs expand to the next [`TAB_WIDTH`]-aligned column, so they need the @@ -180,13 +184,27 @@ impl View for TextView { if take == 0 { return Some(DisplayCoord::new(row_idx as u32, 0)); } - let mut bytes = vec![0u8; take]; - buf.snapshot_rope().slice(line_start, pos, &mut bytes); - // Drop trailing bytes that don't form a complete codepoint. - while !bytes.is_empty() && std::str::from_utf8(&bytes).is_err() { - bytes.pop(); - } - let s = std::str::from_utf8(&bytes).unwrap_or(""); + // Copy [line_start, pos) into a stack buffer for the common short-line + // case, hitting the heap only for unusually long prefixes. This removes + // the per-call allocation that previously ran on every cursor move. + let mut stack_buf = [0u8; STACK_CAP]; + let mut heap_buf: Vec; + let bytes: &mut [u8] = if take <= STACK_CAP { + &mut stack_buf[..take] + } else { + heap_buf = vec![0u8; take]; + &mut heap_buf + }; + buf.snapshot_rope().slice(line_start, pos, bytes); + // If `pos` fell inside a multi-byte codepoint, keep only the bytes up to + // the last complete codepoint. `valid_up_to()` gives that boundary in + // one step, replacing the old pop-one-byte-and-revalidate loop. (Only + // trailing bytes can be invalid here, since the slice is a prefix of + // valid UTF-8 cut at `pos`.) + let s = match std::str::from_utf8(bytes) { + Ok(valid) => valid, + Err(e) => std::str::from_utf8(&bytes[..e.valid_up_to()]).unwrap(), + }; let mut col: u32 = 0; for ch in s.chars() { col += char_display_width(ch, col); diff --git a/tests/cua_region_acceptance.rs b/tests/cua_region_acceptance.rs new file mode 100644 index 0000000..e7e6676 --- /dev/null +++ b/tests/cua_region_acceptance.rs @@ -0,0 +1,174 @@ +//! CUA region semantics — Backspace / Delete consume the active +//! selection (set by Shift+motion) before falling back to their +//! single-codepoint behavior. +//! +//! Regression for the pmacs-gpu report "select a region with +//! shift+arrows then backspace doesn't delete as expected": the +//! `buffer.delete-backward` / `buffer.delete-forward` commands called +//! straight into the single-codepoint core primitives and never +//! consulted `active_region()`. The behavior is frontend-agnostic +//! (the GPU round-trips BS through the same dispatch), so the TUI +//! dispatch path exercised here covers both. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + } +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +/// `(buffer text, region active?, cursor)` probed through the Lua +/// surface — the same introspection a user-facing script would use. +fn probe(s: &EditorState) -> (String, bool, i64) { + s.lua_host + .lua() + .load( + " + local b = pmacs.window.buffer() + local text = b:slice(0, b:len()) + return text, pmacs.editor.region() ~= nil, pmacs.editor.cursor() + ", + ) + .eval() + .expect("probe buffer state") +} + +#[test] +fn backspace_deletes_the_shift_selected_region() { + let mut s = EditorState::new(); + type_str(&mut s, "hello"); + + // Shift+Left three times: region [2, 5), cursor at 2. + for _ in 0..3 { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::SHIFT)); + } + let (_, region_active, _) = probe(&s); + assert!(region_active, "shift+arrows must leave an active region"); + + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Backspace, KeyModifiers::NONE), + ); + + let (text, region_active, cursor) = probe(&s); + assert_eq!(text, "he", "backspace must delete the whole region"); + assert!(!region_active, "the region clears with its deletion"); + assert_eq!(cursor, 2, "cursor lands at the deleted region's start"); + + // Without a region, backspace keeps single-codepoint semantics. + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Backspace, KeyModifiers::NONE), + ); + let (text, _, cursor) = probe(&s); + assert_eq!(text, "h", "no region ⇒ plain single-codepoint backspace"); + assert_eq!(cursor, 1); +} + +/// C-Backspace deletes the previous word (and C-Delete the next), +/// mirroring C-arrow word motion. The pmacs-gpu frontend forwards +/// chorded deletion keys to this same dispatch path. +#[test] +fn ctrl_backspace_deletes_the_previous_word() { + let mut s = EditorState::new(); + type_str(&mut s, "alpha beta"); + + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Backspace, KeyModifiers::CONTROL), + ); + let (text, _, cursor) = probe(&s); + assert_eq!( + text, "alpha ", + "C-BS deletes back through the previous word" + ); + assert_eq!(cursor, 6); + + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::CONTROL)); + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Delete, KeyModifiers::CONTROL), + ); + let (text, _, cursor) = probe(&s); + assert_eq!(text, " ", "C-DEL deletes forward through the next word"); + assert_eq!(cursor, 0); +} + +#[test] +fn typing_replaces_the_shift_selected_region() { + let mut s = EditorState::new(); + type_str(&mut s, "hello"); + + // Select "llo" (region [2, 5), cursor at 2), then type 'X': + // CUA type-over replaces the region with the typed char. + for _ in 0..3 { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::SHIFT)); + } + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('X'), KeyModifiers::SHIFT), + ); + + let (text, region_active, cursor) = probe(&s); + assert_eq!(text, "heX", "typing must replace the selected region"); + assert!(!region_active, "the region is consumed by the replacement"); + assert_eq!(cursor, 3, "cursor sits after the typed char"); + + // Enter over a selection replaces it with a newline. + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::SHIFT)); + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Enter, KeyModifiers::NONE)); + let (text, region_active, cursor) = probe(&s); + assert_eq!(text, "he\n", "Enter must replace the selected region"); + assert!(!region_active); + assert_eq!(cursor, 3); + + // Without a selection, typing keeps plain insert semantics. + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('z'), KeyModifiers::NONE), + ); + let (text, _, cursor) = probe(&s); + assert_eq!(text, "he\nz", "no region ⇒ plain insert at the cursor"); + assert_eq!(cursor, 4); +} + +#[test] +fn delete_forward_deletes_the_shift_selected_region() { + let mut s = EditorState::new(); + type_str(&mut s, "world"); + + // Shift+Home-equivalent: extend left over the whole word. + for _ in 0..5 { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::SHIFT)); + } + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Delete, KeyModifiers::NONE)); + + let (text, region_active, cursor) = probe(&s); + assert_eq!(text, "", "Delete must consume the whole region"); + assert!(!region_active); + assert_eq!(cursor, 0); + + // Without a region, Delete keeps forward single-codepoint semantics. + type_str(&mut s, "ab"); + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::NONE)); + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::NONE)); + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Delete, KeyModifiers::NONE)); + let (text, _, cursor) = probe(&s); + assert_eq!(text, "b", "no region ⇒ plain forward delete at cursor"); + assert_eq!(cursor, 0); +} diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 675695e..9e1dfd9 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -5158,6 +5158,112 @@ fn m4_12_default_bundle_wires_commands_and_keymaps() { assert!(probe.get::("cmd_sig").unwrap()); } +/// Typing-perf: the default bundle coalesces full-document +/// `didChange` notifications instead of sending one per keystroke +/// (each send copies the whole buffer several times and writes +/// O(file) JSON to the server pipe). The after-edit hook only bumps +/// the version and records the buffer dirty; the notification ships +/// on the async tick after the quiet window, or synchronously when a +/// request path flushes via `pmacs.lsp._flush_did_changes`. Observed +/// by monkeypatching `pmacs.lsp.did_change` (the bundle resolves it +/// dynamically at flush time) and firing `buffer.after-edit` through +/// the public hook runner. +#[test] +fn m4_lua_bundle_debounces_did_change_per_keystroke() { + use pmacs::editor::EditorState; + + let mut s = EditorState::new(); + let fake = fake_lsp_path(); + let dir = tempfile::TempDir::new().unwrap(); + let file = dir.path().join("debounce.rs"); + std::fs::write(&file, "fn main() {}\n").unwrap(); + let file_disp = file.display(); + + // Point the rust config at the fake server, open the file (the + // after-load hook auto-attaches and sends didOpen v1), then + // instrument did_change. + s.lua_host + .lua() + .load(format!( + " + pmacs.lsp.config.rust = {{ command = '{fake}' }} + pmacs.buffer.find_or_open('{file_disp}') + _G.__sent_did_changes = {{}} + local real = pmacs.lsp.did_change + pmacs.lsp.did_change = function(sid, uri, version, text) + table.insert(_G.__sent_did_changes, {{ version = version, len = #text }}) + return real(sid, uri, version, text) + end + " + )) + .exec() + .expect("configure + open + instrument"); + + // Three "keystrokes" in a burst: nothing may ship inline. + s.lua_host + .lua() + .load("for _ = 1, 3 do pmacs.hook.run('buffer.after-edit') end") + .exec() + .expect("fire after-edit burst"); + let sent: i64 = s + .lua_host + .lua() + .load("return #_G.__sent_did_changes") + .eval() + .expect("count sends"); + assert_eq!(sent, 0, "didChange must not ship per keystroke"); + + // Request-path flush: exactly one coalesced notification carrying + // the latest version (didOpen was v1, three edits bump to v4 — + // skipped intermediate versions are legal, LSP only requires + // strictly increasing). + let (sent, version): (i64, i64) = s + .lua_host + .lua() + .load( + " + pmacs.lsp._flush_did_changes() + local n = #_G.__sent_did_changes + local v = n > 0 and _G.__sent_did_changes[n].version or -1 + return n, v + ", + ) + .eval() + .expect("flush + count"); + assert_eq!( + sent, 1, + "explicit flush ships exactly one coalesced didChange" + ); + assert_eq!( + version, 4, + "flush carries the latest version (v1 open + 3 edits)" + ); + + // Time-based flush: one more edit, then tick after the quiet + // window (75ms in the bundle) has elapsed. + s.lua_host + .lua() + .load("pmacs.hook.run('buffer.after-edit')") + .exec() + .expect("fire single after-edit"); + std::thread::sleep(Duration::from_millis(120)); + s.tick_async(); + let (sent, version): (i64, i64) = s + .lua_host + .lua() + .load( + " + local n = #_G.__sent_did_changes + local v = n > 0 and _G.__sent_did_changes[n].version or -1 + return n, v + ", + ) + .eval() + .expect("count after tick"); + assert_eq!(sent, 2, "quiet-window tick flushes the pending didChange"); + assert_eq!(version, 5, "tick flush carries the post-edit version"); +} + /// Defensive: the auto-attach hook ignores buffers that don't have a /// language config, doesn't crash on `*scratch*`, and pcall-wraps the /// spawn so a missing server binary in the user's PATH doesn't poison diff --git a/tests/m9_2_acceptance.rs b/tests/m9_2_acceptance.rs index 2e98b16..06338c9 100644 --- a/tests/m9_2_acceptance.rs +++ b/tests/m9_2_acceptance.rs @@ -536,10 +536,15 @@ fn m9_2_cancelled_sibling_wins_over_queued_response() { .read_resource(sid, "file:///race") .expect("read c"); - // Let the fake server finish its delayed response, then harvest - // the supervisor event queue without giving McpManager a chance - // to process it yet. - std::thread::sleep(Duration::from_millis(350)); + // Let the fake server finish its delayed response (it sleeps + // 250ms), then harvest the supervisor event queue without giving + // McpManager a chance to process it yet. The margin over the + // fake's delay must absorb two pipe transits plus the request's + // queued-stdin-writer hop under CI load (a 100ms margin flaked on + // macOS runners); a generous wait does not weaken the contract — + // the race under test is cancel-AFTER-queue-BEFORE-manager-tick, + // which holds for any wait long enough for the response to land. + std::thread::sleep(Duration::from_secs(1)); sup.borrow_mut().tick(); // Cancel only b after the response is queued but before