Merge pull request #60 from levineuwirth/session-b1-keyboard-cursor
pmacs-gpu editor: optimistic typing arc — input, perf, and CUA selection
This commit is contained in:
commit
7fdae766e4
|
|
@ -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 --------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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-<home>", "cursor.select-line-start")
|
|||
bind("S-<end>", "cursor.select-line-end")
|
||||
bind("C-S-<left>", "cursor.select-word-left")
|
||||
bind("C-S-<right>", "cursor.select-word-right")
|
||||
bind("C-S-<up>", "cursor.select-paragraph-up")
|
||||
bind("C-S-<down>", "cursor.select-paragraph-down")
|
||||
|
||||
-- Undo / redo ----------------------------------------------------------------
|
||||
--
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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::<FrontendEvent>();
|
||||
|
||||
// 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<Mutex<...>>` 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<Mutex<UnixStream>>,
|
||||
writer_tx: mpsc::Sender<FrontendEvent>,
|
||||
/// 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 {
|
||||
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",
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
231
src/buffer.rs
231
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<Vec<u8>>, Option<Box<crate::rope::CrdtOp>>);
|
||||
|
||||
/// 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<loro::TextDelta>]) -> 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<loro::TextDelta>]) -> 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<Position> {
|
||||
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
|
||||
|
|
|
|||
91
src/crdt.rs
91
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<Mutex<Vec<Vec<TextDelta>>>>;
|
||||
type TextDeltaSubscription = (TextDeltaBatches, Arc<AtomicBool>, 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<AtomicBool>,
|
||||
_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::<Vec<TextDelta>>::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<Vec<Vec<TextDelta>>> {
|
||||
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<usize> {
|
||||
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`.
|
||||
|
|
|
|||
224
src/daemon.rs
224
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,10 +1343,22 @@ 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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let term_size = *term_sizes
|
||||
.get(&source)
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
235
src/editor.rs
235
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<MouseClickState>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
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,14 +751,33 @@ 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)`,
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
45
src/lsp.rs
45
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
|
||||
|
|
|
|||
|
|
@ -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::time::Instant> = 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(
|
||||
|
|
|
|||
212
src/process.rs
212
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<Box<dyn Write + Send>>,
|
||||
stdin: Option<StdinWriter>,
|
||||
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<AtomicBool>,
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>>,
|
||||
/// Bytes accepted by [`Self::write`] but not yet written by the
|
||||
/// thread. Backpressure signal for the queue budget.
|
||||
queued_bytes: Arc<AtomicUsize>,
|
||||
/// 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<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl StdinWriter {
|
||||
fn spawn(mut sink: Box<dyn Write + Send>) -> Self {
|
||||
let (tx, rx) = channel::unbounded::<Vec<u8>>();
|
||||
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<RuntimeHand
|
|||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.map(|s| Box::new(s) as Box<dyn Write + Send>);
|
||||
.map(|s| StdinWriter::spawn(Box::new(s) as Box<dyn Write + Send>));
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
let (byte_tx, byte_rx) = channel::bounded::<ByteChunk>(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();
|
||||
|
|
|
|||
|
|
@ -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<BufferId, StyleGate>,
|
||||
/// 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<BufferId, DiagLineCache>,
|
||||
}
|
||||
|
||||
/// 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<u64>,
|
||||
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,6 +312,7 @@ impl SemanticRenderState {
|
|||
generation,
|
||||
},
|
||||
);
|
||||
if !suppress_empty_generation_bump {
|
||||
out.push(InstanceMessage::Decorations {
|
||||
buffer_id: vp.buffer_id,
|
||||
generation,
|
||||
|
|
@ -262,6 +322,7 @@ impl SemanticRenderState {
|
|||
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<InstanceMessage> {
|
||||
// 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<InstanceMessage> {
|
||||
// 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,35 +528,25 @@ impl SemanticRenderState {
|
|||
}
|
||||
}
|
||||
|
||||
fn scoped_decorations(&self, state: &EditorState, vp: &DeclaredViewport) -> Vec<Decoration> {
|
||||
fn scoped_decorations(
|
||||
&mut self,
|
||||
state: &EditorState,
|
||||
vp: &DeclaredViewport,
|
||||
) -> Vec<Decoration> {
|
||||
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<u8>, Vec<u64>)> = 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
|
||||
{
|
||||
if let Some((lo, hi)) = win.region()
|
||||
&& let Some((lo, hi)) = win.region()
|
||||
&& let Some(range) = clip_to_viewport(lo, hi, vp)
|
||||
{
|
||||
out.push(Decoration {
|
||||
|
|
@ -480,21 +554,6 @@ impl SemanticRenderState {
|
|||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics — keyed in the shared store by the file URI the
|
||||
// Lua LSP glue opened the document under. The URI is derived
|
||||
|
|
@ -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(|| {
|
||||
// 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);
|
||||
let ls = line_start_offsets(&s);
|
||||
(s, ls)
|
||||
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 source_len = source.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<Decoration> {
|
|||
.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<u8> {
|
|||
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<u64> {
|
||||
|
|
@ -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<StyleSp
|
|||
}
|
||||
|
||||
let capture_names = query.capture_names();
|
||||
let highlights = crate::syntax::compute_highlight_spans(&query, &bundle);
|
||||
// Scope the tree-sitter capture walk to the visible byte range so
|
||||
// re-styling on each edit is O(visible), not O(file) — the typing
|
||||
// bottleneck on large files (framing Q#S6). Captures whose nodes
|
||||
// intersect the range are returned, then clipped exactly below.
|
||||
let highlights = crate::syntax::compute_highlight_spans_in_range(
|
||||
&query,
|
||||
&bundle,
|
||||
Some(vis_start as usize..vis_end as usize),
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
for hs in highlights {
|
||||
let s = u64::from(hs.start_byte).max(vis_start);
|
||||
|
|
@ -1272,30 +1396,9 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn current_line_range_finds_enclosing_line() {
|
||||
// "abc\nde\nfgh": line_starts = [0, 4, 7]; source_len = 10.
|
||||
let line_starts = vec![0u64, 4, 7];
|
||||
let len = 10u64;
|
||||
|
||||
// Cursor at byte 0 → line 0 = [0, 4).
|
||||
assert_eq!(current_line_range(&line_starts, len, 0), (0, 4));
|
||||
// Cursor anywhere within line 0 → still line 0.
|
||||
assert_eq!(current_line_range(&line_starts, len, 3), (0, 4));
|
||||
// Cursor on the newline byte still belongs to the line it
|
||||
// terminates.
|
||||
assert_eq!(current_line_range(&line_starts, len, 3), (0, 4));
|
||||
// Cursor at line 1 start → line 1 = [4, 7).
|
||||
assert_eq!(current_line_range(&line_starts, len, 4), (4, 7));
|
||||
// Cursor in last line → [7, len).
|
||||
assert_eq!(current_line_range(&line_starts, len, 8), (7, 10));
|
||||
// Cursor at exactly source_len (past last byte) → still last
|
||||
// line; clamps cleanly without indexing out.
|
||||
assert_eq!(current_line_range(&line_starts, len, len), (7, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_line_projects_as_a_decoration_for_cursor_on_seed() {
|
||||
// "abc\nde": cursor at byte 0 → CurrentLine = [0, 4).
|
||||
fn semantic_projection_does_not_emit_current_line_decoration() {
|
||||
// CurrentLine is a frontend-local visual for semantic sessions;
|
||||
// the daemon should not copy the whole buffer to derive it.
|
||||
let state = empty_state();
|
||||
let buffer_id = active_buffer(&state);
|
||||
seed_diagnostic(&state, buffer_id);
|
||||
|
|
@ -1304,23 +1407,14 @@ mod tests {
|
|||
|
||||
let (_full, decos) =
|
||||
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
|
||||
let current = decos
|
||||
.iter()
|
||||
.find(|d| d.kind == DecorationKind::CurrentLine)
|
||||
.expect("CurrentLine present (cursor on line 0)");
|
||||
assert_eq!(
|
||||
current.range,
|
||||
ByteRange { start: 0, end: 4 },
|
||||
"line 0 of \"abc\\nde\" spans bytes [0, 4)"
|
||||
assert!(
|
||||
decos.iter().all(|d| d.kind != DecorationKind::CurrentLine),
|
||||
"semantic projection must not emit CurrentLine; got {decos:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_line_skipped_when_active_window_is_a_different_buffer() {
|
||||
// Producer must only emit per-window state for windows whose
|
||||
// active buffer matches the projected viewport. The vp.buffer_id
|
||||
// regression test (decorations_use_vp_buffer_not_active_buffer)
|
||||
// exercises this for Selection; assert it for CurrentLine too.
|
||||
fn semantic_current_line_absence_does_not_depend_on_active_buffer() {
|
||||
let state = empty_state();
|
||||
let scratch_id = active_buffer(&state);
|
||||
let file_id = {
|
||||
|
|
@ -1338,17 +1432,15 @@ mod tests {
|
|||
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
|
||||
assert!(
|
||||
decos.iter().all(|d| d.kind != DecorationKind::CurrentLine),
|
||||
"CurrentLine must not project against a viewport whose buffer is not the active window's buffer; got {decos:?}"
|
||||
"semantic projection must not emit CurrentLine for any viewport; got {decos:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_line_cursor_motion_does_not_re_emit_decorations() {
|
||||
// Q#3 stance β: horizontal cursor motion within the same line
|
||||
// must not re-ship a Decorations frame. The existing M11.4
|
||||
// changed_intervals diff gives this for free — same line means
|
||||
// identical decoration ranges means an empty interval list
|
||||
// means no emission.
|
||||
fn cursor_motion_does_not_re_emit_decorations() {
|
||||
// Cursor-only movement should not ship Decorations. Semantic
|
||||
// frontends receive CursorByte separately and derive local
|
||||
// cursor visuals without daemon decoration churn.
|
||||
let state = empty_state();
|
||||
let buffer_id = active_buffer(&state);
|
||||
{
|
||||
|
|
@ -1381,20 +1473,15 @@ mod tests {
|
|||
"same-line cursor motion must not re-emit Decorations"
|
||||
);
|
||||
|
||||
// Cross a `\n` (byte 10) → line changes → re-emission.
|
||||
// Cross a `\n` (byte 10). Still no Decorations frame.
|
||||
{
|
||||
let mut core = state.core.borrow_mut();
|
||||
core.active_window_mut().cursor = 12;
|
||||
}
|
||||
let msgs = s.render_frame(&state);
|
||||
let (_full, decos) =
|
||||
decorations_of(&msgs).expect("line-change must ship a Decorations frame");
|
||||
let current = decos
|
||||
.iter()
|
||||
.find(|d| d.kind == DecorationKind::CurrentLine)
|
||||
.expect("CurrentLine present");
|
||||
// Line 1 of "abcdefghij\nklmno" starts at byte 11.
|
||||
assert_eq!(current.range, ByteRange { start: 11, end: 16 });
|
||||
assert!(
|
||||
s.render_frame(&state).is_empty(),
|
||||
"line-crossing cursor motion must not re-emit Decorations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1408,11 +1495,8 @@ mod tests {
|
|||
|
||||
let (_full, decos) =
|
||||
decorations_of(&s.render_frame(&state)).expect("a Decorations message");
|
||||
// Session 9.2 added `CurrentLine` to the projection: line 0
|
||||
// (cursor at byte 0) emits as a `CurrentLine` decoration in
|
||||
// addition to the seeded warning. This test pins the
|
||||
// diagnostic projection's byte math; assert that decoration's
|
||||
// shape rather than the total count.
|
||||
// This test pins the diagnostic projection's byte math without
|
||||
// relying on any cursor-line decoration.
|
||||
let warning = decos
|
||||
.iter()
|
||||
.find(|d| d.kind == DecorationKind::DiagnosticWarning)
|
||||
|
|
@ -1491,6 +1575,94 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Hold-while-stale (diagnostics churn fix): once diagnostics
|
||||
/// have shipped, marking the store stale (which happens per edit)
|
||||
/// must NOT ship a clearing frame — the frontend keeps its
|
||||
/// last-received set, translated through its own local edits,
|
||||
/// until the next `publishDiagnostics`. The pre-fix behavior
|
||||
/// shipped a full empty frame on the first keystroke of every
|
||||
/// burst (diagnostics blinked out, one full frontend reshape) and
|
||||
/// re-added them after the next publish (blink in, another
|
||||
/// reshape).
|
||||
#[test]
|
||||
fn diagnostics_hold_emission_while_store_stale_after_shipping() {
|
||||
let state = empty_state();
|
||||
let buffer_id = active_buffer(&state);
|
||||
seed_diagnostic(&state, buffer_id);
|
||||
|
||||
let mut s = local();
|
||||
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
|
||||
let (_full, decos) =
|
||||
decorations_of(&s.render_frame(&state)).expect("baseline ships the diagnostic");
|
||||
assert!(
|
||||
decos.iter().any(|d| is_diagnostic_kind(d.kind)),
|
||||
"baseline contains the seeded diagnostic; got {decos:?}"
|
||||
);
|
||||
|
||||
let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs"));
|
||||
state
|
||||
.lsp_manager
|
||||
.borrow()
|
||||
.diag_store()
|
||||
.lock()
|
||||
.expect("diag store")
|
||||
.mark_stale(uri.clone());
|
||||
|
||||
assert!(
|
||||
decorations_of(&s.render_frame(&state)).is_none(),
|
||||
"stale store holds Decorations emission instead of shipping a clearing frame"
|
||||
);
|
||||
assert!(
|
||||
decorations_of(&s.render_frame(&state)).is_none(),
|
||||
"the hold is stable across frames"
|
||||
);
|
||||
|
||||
// A selection change during the stale window still ships
|
||||
// (without diagnostic kinds) — the hold must not pin a dead
|
||||
// selection just to protect the diagnostics.
|
||||
set_selection(&state, 0, 2);
|
||||
let (_full, decos) = decorations_of(&s.render_frame(&state))
|
||||
.expect("selection change ships during the stale window");
|
||||
assert!(
|
||||
decos.iter().any(|d| d.kind == DecorationKind::Selection),
|
||||
"fresh selection present; got {decos:?}"
|
||||
);
|
||||
assert!(
|
||||
decos.iter().all(|d| !is_diagnostic_kind(d.kind)),
|
||||
"no stale-positioned diagnostics ride along; got {decos:?}"
|
||||
);
|
||||
|
||||
// The next publishDiagnostics clears the flag; diagnostics
|
||||
// re-emit on the following frame.
|
||||
state
|
||||
.lsp_manager
|
||||
.borrow()
|
||||
.diag_store()
|
||||
.lock()
|
||||
.expect("diag store")
|
||||
.set(
|
||||
&uri,
|
||||
vec![crate::diag::Diagnostic {
|
||||
start_line: 1,
|
||||
start_col: 0,
|
||||
end_line: 1,
|
||||
end_col: 2,
|
||||
severity: crate::diag::DiagnosticSeverity::Warning,
|
||||
message: "x".into(),
|
||||
source: None,
|
||||
code: None,
|
||||
}],
|
||||
);
|
||||
let (_full, decos) = decorations_of(&s.render_frame(&state))
|
||||
.expect("post-publish frame re-ships diagnostics");
|
||||
assert!(
|
||||
decos
|
||||
.iter()
|
||||
.any(|d| d.kind == DecorationKind::DiagnosticWarning),
|
||||
"diagnostics return once the store is fresh; got {decos:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: in a multi-frontend setup the editor's *active*
|
||||
/// buffer (set by `core.active_buffer_id()`, derived from the
|
||||
/// active frontend's view) can differ from the buffer a given
|
||||
|
|
@ -1628,6 +1800,7 @@ mod tests {
|
|||
let buffer_id = active_buffer(&state);
|
||||
let mut s = local();
|
||||
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
|
||||
set_selection(&state, 0, 1);
|
||||
let _ = s.render_frame(&state); // initial full
|
||||
assert!(
|
||||
s.render_frame(&state).is_empty(),
|
||||
|
|
@ -1651,7 +1824,7 @@ mod tests {
|
|||
}
|
||||
|
||||
// Generation transitioned → next frame must be full for both
|
||||
// diff-shaped families.
|
||||
// diff-shaped families when there is state to re-anchor.
|
||||
let msgs = s.render_frame(&state);
|
||||
let (style_full, _) = style_segments(&msgs).expect("StyleSpans re-emitted");
|
||||
let (deco_full, _) = decorations_of(&msgs).expect("Decorations re-emitted");
|
||||
|
|
@ -1892,6 +2065,39 @@ mod tests {
|
|||
sid
|
||||
}
|
||||
|
||||
fn seed_rust_parse_view(
|
||||
state: &EditorState,
|
||||
buffer_id: BufferId,
|
||||
text: &[u8],
|
||||
) -> 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")]);
|
||||
|
|
|
|||
|
|
@ -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<HighlightSpan> {
|
||||
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<std::ops::Range<usize>>,
|
||||
) -> Vec<HighlightSpan> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u8>;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -5158,6 +5158,112 @@ fn m4_12_default_bundle_wires_commands_and_keymaps() {
|
|||
assert!(probe.get::<bool>("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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue