From 5eec8a6f1016c6a9debc0c9e6b8e57315f8f84e5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 29 May 2026 12:15:33 -0400 Subject: [PATCH 01/19] =?UTF-8?q?session=20B1=20=E2=80=94=20keyboard=20cur?= =?UTF-8?q?sor=20motion=20in=20pmacs-gpu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Phase B session: pmacs-gpu can move its own cursor. Consumer-only (the daemon already dispatches FrontendEvent::Key through the same keymap/command stack the TUI uses; verified in the Phase B framing). - `AttachClient::send_key` emits `FrontendEvent::Key`. - `translate_key` maps winit logical key + modifier state → protocol (Key, Modifiers). Covers the full editing set; `is_motion_key` gates B1 to cursor-motion keys only (arrows, Home/End, PageUp/PageDown) so no buffer mutation happens yet — editing keys open in B2 by dropping the gate. Modifiers tracked via winit `ModifiersChanged`. - `window_event` rework: Escape stays a local quit; other pressed keys translate and (motion-gated) `send_key`. - Consume `InstanceMessage::CursorByte` → `own_cursor` (Q#B3: the daemon is authoritative; the caret follows whatever it reports, incl. command-driven motion this frontend never interprets). - Caret: a thin quad bar drawn *over* the text at the cursor glyph, byte→glyph mapping rebased per line via `line_byte_offsets[line_i]` (bet B4 / the QB3 lesson applied up front). - Un-suppress own-window `Selection`/`CurrentLine` washes from `current_decorations` alongside peer presence (Q#B4): the QB1 suppression lifts now that the own cursor is live. The bg-wash builder split into `collect_own_decoration_rects` + `collect_peer_rects`. - own_cursor cleared on BufferSnapshot (prior-buffer offsets). Tests: `translate_key_maps_motion_named_keys_and_chars`, `translate_key_carries_modifiers`. pmacs-gpu unit 18 (+2). Gates green: fmt; clippy --all-targets --workspace -D warnings (default + crdt); pmacs-gpu unit 18. Daemon/lib untouched. NOT YET VISUALLY VALIDATED — per the Phase B framing's process correction, this must be confirmed in a running pmacs-gpu (arrow keys move the caret + own current-line wash; TUI unaffected) before merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/attach.rs | 27 ++- pmacs-gpu/src/main.rs | 353 ++++++++++++++++++++++++++++++++++------ 2 files changed, 324 insertions(+), 56 deletions(-) diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index b5e1a4e..e683d13 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -23,8 +23,8 @@ 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, + InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, + TransportError, is_supported_protocol_version, read_message, write_message, }; use winit::event_loop::EventLoopProxy; @@ -228,4 +228,27 @@ impl AttachClient { }, ) } + + /// 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> { + let mut stream = self + .write_stream + .lock() + .expect("attach write-stream mutex poisoned"); + write_message( + &mut *stream, + &FrontendEvent::Key(KeyEvent { + frontend_id: self.frontend_id, + key, + mods, + timestamp_ns: 0, + }), + ) + } } diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index b4dd44b..76d0c29 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -34,14 +34,14 @@ use glyphon::{ }; use pmacs_protocol::{ AdornmentContent, AdornmentPlacement, BufferId, ByteRange, Decoration, DecorationKind, - DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, SelectionSnapshot, - StyleSegment, StyleSpan, + DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, Key as ProtocolKey, Modifiers, + SelectionSnapshot, StyleSegment, StyleSpan, cell::{Color as CellColor, Style as CellStyle}, }; use wgpu::MultisampleState; use wgpu::util::DeviceExt; use winit::application::ApplicationHandler; -use winit::event::{ElementState, KeyEvent, WindowEvent}; +use winit::event::{ElementState, WindowEvent}; use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::keyboard::{Key, NamedKey}; use winit::window::{Window, WindowId}; @@ -65,6 +65,11 @@ const BG: wgpu::Color = wgpu::Color { const TEXT_LEFT: f32 = 16.0; const TEXT_TOP: f32 = 16.0; +/// Caret bar width in px, and its color (bright, near-opaque — drawn +/// over the text so it reads as the active insertion point). Session +/// B1. +const CARET_WIDTH: f32 = 2.0; +const CARET_COLOR: [f32; 4] = [0.90, 0.90, 0.96, 0.90]; const TEXT_RIGHT_GAP: f32 = 10.0; const MINIMAP_WIDTH: f32 = 48.0; const MINIMAP_RIGHT: f32 = 12.0; @@ -151,6 +156,7 @@ fn main() { proxy: Some(proxy), state: None, attach_client: None, + modifiers: winit::keyboard::ModifiersState::empty(), }; event_loop .run_app(&mut app) @@ -203,9 +209,12 @@ struct App { proxy: Option>, state: Option, /// Held both for stream lifetime and for the main loop's - /// `send_viewport` write-back path. Session 4 uses this; later - /// sessions will add cursor/edit/focus emissions. + /// `send_viewport` / `send_key` write-back path. attach_client: Option, + /// Latest modifier state from winit (`ModifiersChanged`). winit + /// delivers modifiers separately from key presses, so we track the + /// current set and apply it when a key is sent (session B1). + modifiers: winit::keyboard::ModifiersState, } /// All resources owned by one running pmacs-gpu instance. @@ -280,6 +289,20 @@ struct State { /// these entries rather than from `current_decorations`. Sender /// exclusion at the daemon means our own id never appears here. peer_presences: HashMap, + /// This frontend's own cursor (session B1), from the daemon's + /// `CursorByte`. pmacs-gpu sends `Key` events; the daemon moves the + /// authoritative window cursor and reports it back here (Q#B3), so + /// the caret follows whatever the daemon decided — including motion + /// from commands this frontend never interprets locally. `None` + /// until the first `CursorByte`. + own_cursor: Option, +} + +/// pmacs-gpu's own cursor position, mirrored from `CursorByte`. +#[derive(Clone, Copy, Debug)] +struct OwnCursor { + buffer_id: BufferId, + byte: u64, } /// One peer frontend's cursor + selection in a buffer, from @@ -336,22 +359,40 @@ impl ApplicationHandler for App { } fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { - let Some(state) = self.state.as_mut() else { - return; - }; match event { - WindowEvent::CloseRequested - | WindowEvent::KeyboardInput { - event: - KeyEvent { - logical_key: Key::Named(NamedKey::Escape), - state: ElementState::Pressed, - .. - }, - .. - } => event_loop.exit(), - WindowEvent::Resized(size) => state.resize(size.width.max(1), size.height.max(1)), - WindowEvent::RedrawRequested => state.render(), + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::ModifiersChanged(mods) => self.modifiers = mods.state(), + WindowEvent::KeyboardInput { event: key, .. } => { + if key.state != ElementState::Pressed { + return; + } + // Escape stays a local quit (no daemon round trip). + if matches!(key.logical_key, Key::Named(NamedKey::Escape)) { + event_loop.exit(); + return; + } + // Session B1 forwards cursor-motion keys only; editing + // keys (chars, Backspace, Enter, Delete) open in B2. + // `translate_key` handles the full set so B2 just drops + // the `is_motion_key` gate. + if let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers) + && is_motion_key(pkey) + && let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_key(pkey, pmods) + { + eprintln!("pmacs-gpu: send_key failed: {e}"); + } + } + WindowEvent::Resized(size) => { + if let Some(state) = self.state.as_mut() { + state.resize(size.width.max(1), size.height.max(1)); + } + } + WindowEvent::RedrawRequested => { + if let Some(state) = self.state.as_mut() { + state.render(); + } + } _ => {} } } @@ -563,6 +604,7 @@ impl State { current_adornments: Vec::new(), current_summary: None, peer_presences: HashMap::new(), + own_cursor: None, } } @@ -646,11 +688,12 @@ impl State { self.current_decorations.clear(); self.current_adornments.clear(); self.current_summary = None; - // Peer cursors are anchored in the prior buffer's - // coordinate space; drop them so a stale offset can't - // paint against the new rope before the next - // PresenceUpdate arrives. + // Peer cursors and our own cursor are anchored in the + // prior buffer's coordinate space; drop them so a stale + // offset can't paint against the new rope before the + // next PresenceUpdate / CursorByte arrives. self.peer_presences.clear(); + self.own_cursor = None; if !self.set_text(&text) { self.reshape(); } @@ -795,6 +838,21 @@ impl State { self.window.request_redraw(); None } + // Session B1 — our own cursor. The daemon emits this per + // tick for the replica; the caret + own-window decorations + // follow it. Only meaningful once we send Key events that + // move it. + InstanceMessage::CursorByte { + buffer_id, + byte_pos, + } => { + self.own_cursor = Some(OwnCursor { + buffer_id, + byte: byte_pos, + }); + self.window.request_redraw(); + None + } _ => None, } } @@ -1049,6 +1107,16 @@ impl State { usage: wgpu::BufferUsages::VERTEX, }) }); + let caret_vertices = self.caret_vertex_bytes(); + let caret_vertex_count = (caret_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; + let caret_buffer = (!caret_vertices.is_empty()).then(|| { + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("pmacs-gpu caret"), + contents: &caret_vertices, + usage: wgpu::BufferUsages::VERTEX, + }) + }); let after_bg = debug_frame().then(std::time::Instant::now); let minimap_vertices = self.minimap_vertex_bytes(); let minimap_vertex_count = (minimap_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; @@ -1122,6 +1190,12 @@ impl State { self.text_renderer .render(&self.atlas, &self.viewport, &mut pass) .expect("text_renderer render"); + // Caret over the text so the insertion point reads on top + // of the glyph it sits before (session B1). + if let Some(vertex_buffer) = caret_buffer.as_ref() { + self.quad_renderer + .render(&mut pass, vertex_buffer, caret_vertex_count); + } if let Some(vertex_buffer) = minimap_buffer.as_ref() { self.quad_renderer .render(&mut pass, vertex_buffer, minimap_vertex_count); @@ -1180,58 +1254,120 @@ impl State { rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } - /// Vertex bytes for quad-pipeline background rectangles. Session - /// 9.3 sources `CurrentLine` / `Selection` washes from peer - /// presence (the editing frontend's cursor + selection) rather - /// than from `current_decorations`: this is a read-only mirror, so - /// its own per-window `Selection` / `CurrentLine` decorations are - /// inert (cursor pinned at 0, no selection). See finding QB1 in - /// `docs/pmacs-gpu-quad-backgrounds-framing.md`. + /// Vertex bytes for quad-pipeline background washes (drawn *under* + /// the text). Two sources, both `Selection` / `CurrentLine`: this + /// frontend's *own* window decorations from `current_decorations` + /// (live again since session B1 reactivated the own cursor — Q#B4; + /// QB1 had suppressed them while the mirror was read-only), and + /// *peer* presence from `PresenceUpdate` (session 9.3). Both reuse + /// the same `\n`-line offset table to rebase cosmic-text's + /// line-relative glyph offsets (QB3). The caret is separate (drawn + /// *over* text) — see [`Self::caret_vertex_bytes`]. fn decoration_background_vertex_bytes(&self) -> Vec { - let rects = self.peer_background_rects(); - rects_to_vertex_bytes(&rects, self.config.width, self.config.height) - } - - /// Background rectangles for every peer's cursor line + selection - /// in the current buffer. `CurrentLine` covers the source line - /// holding the peer cursor; `Selection` covers the peer's selected - /// byte range. Both map byte ranges to per-visual-line glyph - /// extents via `peer_glyph_extent_rects`. Single-peer mirrors reuse - /// the `Selection` / `CurrentLine` colors so the visual reads as - /// "my editing, mirrored"; per-peer distinct colors are deferred. - fn peer_background_rects(&self) -> Vec { let Some(buffer_id) = self.current_buffer_id else { return Vec::new(); }; - let text_len = self.current_text.len() as u64; - // Buffer-absolute byte offset of each `\n`-delimited line, - // indexed by `LayoutRun::line_i`. `LayoutGlyph::{start,end}` are - // offsets within the *original line*, not the whole buffer, so - // every byte range below must be rebased per line before it can - // be matched against glyph offsets. let line_offsets = line_byte_offsets(&self.current_text); let mut rects = Vec::new(); + self.collect_own_decoration_rects(&mut rects, &line_offsets); + self.collect_peer_rects(buffer_id, &line_offsets, &mut rects); + rects_to_vertex_bytes(&rects, self.config.width, self.config.height) + } + + /// Own-window `Selection` / `CurrentLine` washes from + /// `current_decorations` (Q#B4). The producer emits these for this + /// frontend's window; they become non-trivial once B1's cursor + /// motion moves the window cursor off byte 0. + fn collect_own_decoration_rects(&self, rects: &mut Vec, line_offsets: &[u64]) { + for d in &self.current_decorations { + if let Some(color) = decoration_kind_to_bg_color(d.kind) { + self.push_glyph_extent_rects( + rects, + line_offsets, + d.range.start, + d.range.end, + color, + ); + } + } + } + + /// Peer cursor-line + selection washes from `PresenceUpdate` + /// (session 9.3). Single-peer mirrors reuse the `Selection` / + /// `CurrentLine` colors; per-peer distinct colors are deferred. + fn collect_peer_rects( + &self, + buffer_id: BufferId, + line_offsets: &[u64], + rects: &mut Vec, + ) { + let text_len = self.current_text.len() as u64; for presence in self.peer_presences.values() { if presence.buffer_id != buffer_id { continue; } - // CurrentLine: the source line containing the peer cursor. if let Some(color) = decoration_kind_to_bg_color(DecorationKind::CurrentLine) { let (lo, hi) = source_line_range(&self.current_text, presence.cursor); - self.push_glyph_extent_rects(&mut rects, &line_offsets, lo, hi, color); + self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); } - // Selection: the peer's selected byte range, normalized. if let Some(sel) = presence.selection && let Some(color) = decoration_kind_to_bg_color(DecorationKind::Selection) { let lo = sel.anchor.min(sel.active).min(text_len); let hi = sel.anchor.max(sel.active).min(text_len); if hi > lo { - self.push_glyph_extent_rects(&mut rects, &line_offsets, lo, hi, color); + self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); } } } - rects + } + + /// Vertex bytes for the caret quad, drawn *over* the text (B1). + /// Empty when no own cursor is known or it's in another buffer. + fn caret_vertex_bytes(&self) -> Vec { + let line_offsets = line_byte_offsets(&self.current_text); + let Some(rect) = self.caret_rect(&line_offsets) else { + return Vec::new(); + }; + rects_to_vertex_bytes(&[rect], self.config.width, self.config.height) + } + + /// The caret rectangle for the own cursor: a thin bar at the left + /// edge of the glyph the cursor sits before (or the right edge of + /// the last glyph when the cursor is at line end). Byte→glyph + /// mapping rebases per line (QB3) and keys off the cursor's source + /// line via [`source_line_range`]. + fn caret_rect(&self, line_offsets: &[u64]) -> Option { + let own = self.own_cursor?; + if self.current_buffer_id != Some(own.buffer_id) { + return None; + } + let cursor = own.byte.min(self.current_text.len() as u64); + let (line_lo, _) = source_line_range(&self.current_text, cursor); + for run in self.buffer.layout_runs() { + if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo { + continue; + } + let line_base = line_lo; + let mut x = TEXT_LEFT; + for glyph in run.glyphs { + if line_base + glyph.start as u64 >= cursor { + x = TEXT_LEFT + glyph.x; + break; + } + // Cursor is past this glyph; track its right edge so a + // cursor at line end lands after the final glyph. + x = TEXT_LEFT + glyph.x + glyph.w; + } + return Some(MinimapRect { + x, + y: TEXT_TOP + run.line_top, + w: CARET_WIDTH, + h: run.line_height, + color: CARET_COLOR, + }); + } + None } /// Push one rect per visual line whose glyphs overlap the @@ -1575,6 +1711,70 @@ fn debug_frame() -> bool { *FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_FRAME").is_some()) } +/// Translate a winit logical key + current modifier state into a +/// protocol `(Key, Modifiers)`. Returns `None` for keys the protocol +/// has no representation for (the daemon ignores `Key::Unknown`, so +/// there's no value in forwarding them). `translate_key` covers the +/// full editing set; session B1 gates the send on [`is_motion_key`]. +fn translate_key( + logical: &Key, + mods: winit::keyboard::ModifiersState, +) -> Option<(ProtocolKey, Modifiers)> { + let mut bits = 0u8; + if mods.shift_key() { + bits |= Modifiers::SHIFT.bits(); + } + if mods.control_key() { + bits |= Modifiers::CTRL.bits(); + } + if mods.alt_key() { + bits |= Modifiers::ALT.bits(); + } + if mods.super_key() { + bits |= Modifiers::META.bits(); + } + let pmods = Modifiers::from_bits_truncate(bits); + + let pkey = match logical { + Key::Named(named) => match named { + NamedKey::ArrowLeft => ProtocolKey::Left, + NamedKey::ArrowRight => ProtocolKey::Right, + NamedKey::ArrowUp => ProtocolKey::Up, + NamedKey::ArrowDown => ProtocolKey::Down, + NamedKey::Home => ProtocolKey::Home, + NamedKey::End => ProtocolKey::End, + NamedKey::PageUp => ProtocolKey::PageUp, + NamedKey::PageDown => ProtocolKey::PageDown, + NamedKey::Backspace => ProtocolKey::Backspace, + NamedKey::Enter => ProtocolKey::Enter, + NamedKey::Delete => ProtocolKey::Delete, + NamedKey::Insert => ProtocolKey::Insert, + NamedKey::Tab => ProtocolKey::Tab, + _ => return None, + }, + Key::Character(s) => ProtocolKey::Char(s.chars().next()?), + _ => return None, + }; + Some((pkey, pmods)) +} + +/// Session B1 forwards cursor-motion keys only; editing keys wait for +/// B2. Keeping the gate as a single predicate means B2 removes one +/// call site, not a translation rewrite. +fn is_motion_key(key: ProtocolKey) -> bool { + matches!( + key, + ProtocolKey::Left + | ProtocolKey::Right + | ProtocolKey::Up + | ProtocolKey::Down + | ProtocolKey::Home + | ProtocolKey::End + | ProtocolKey::PageUp + | ProtocolKey::PageDown + ) +} + /// Buffer-absolute byte offset of the start of each `\n`-delimited /// line (index 0 = byte 0). Indexed by cosmic-text's /// `LayoutRun::line_i` to rebase line-relative glyph offsets. @@ -1959,6 +2159,51 @@ mod tests { assert_eq!(source_line_range(text, 99), (7, 10)); } + #[test] + fn translate_key_maps_motion_named_keys_and_chars() { + use winit::keyboard::{Key as WKey, ModifiersState, NamedKey, SmolStr}; + + let none = ModifiersState::empty(); + // Motion named keys translate and are gated as motion. + for (named, expected) in [ + (NamedKey::ArrowLeft, ProtocolKey::Left), + (NamedKey::ArrowRight, ProtocolKey::Right), + (NamedKey::ArrowUp, ProtocolKey::Up), + (NamedKey::ArrowDown, ProtocolKey::Down), + (NamedKey::Home, ProtocolKey::Home), + (NamedKey::End, ProtocolKey::End), + (NamedKey::PageUp, ProtocolKey::PageUp), + (NamedKey::PageDown, ProtocolKey::PageDown), + ] { + let (k, m) = translate_key(&WKey::Named(named), none).expect("named maps"); + assert_eq!(k, expected); + assert!(m.is_empty()); + assert!(is_motion_key(k), "{expected:?} should gate as motion"); + } + + // A character key maps to Char but is NOT a motion key (B1 + // gates it out; B2 opens it). + let (k, _) = translate_key(&WKey::Character(SmolStr::new("a")), none).expect("char maps"); + assert_eq!(k, ProtocolKey::Char('a')); + assert!(!is_motion_key(k)); + + // Editing named keys translate (for B2) but don't gate as motion. + let (bk, _) = translate_key(&WKey::Named(NamedKey::Backspace), none).expect("bksp maps"); + assert_eq!(bk, ProtocolKey::Backspace); + assert!(!is_motion_key(bk)); + } + + #[test] + fn translate_key_carries_modifiers() { + use winit::keyboard::{Key as WKey, ModifiersState, NamedKey}; + + let ctrl = ModifiersState::CONTROL; + let (k, m) = translate_key(&WKey::Named(NamedKey::ArrowLeft), ctrl).expect("maps"); + assert_eq!(k, ProtocolKey::Left); + assert!(m.contains(Modifiers::CTRL)); + assert!(!m.contains(Modifiers::SHIFT)); + } + #[test] fn line_byte_offsets_indexes_each_logical_line() { // "abc\nde\nfgh": lines start at bytes 0, 4, 7. Indexed by From 98ef140a84e47fc72218767a4ad2c2c68a47cd52 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 29 May 2026 12:38:09 -0400 Subject: [PATCH 02/19] =?UTF-8?q?B1=20fix=20=E2=80=94=20route=20semantic-f?= =?UTF-8?q?rontend=20Key=20events=20into=20the=20editor=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual validation found "nothing occurs" when typing in pmacs-gpu. Root cause is daemon-side, not consumer-side: the dispatcher's catch-all arm only called `apply_event` (→ `dispatch_key`) when the source frontend had a `RenderState` — i.e. a grid frontend. A semantic frontend like pmacs-gpu has only a `SemanticRenderState`, so its `Key`/`Mouse`/etc. events hit the `else` branch and were silently dropped (the long-standing "M11.5 scope" posture). So pmacs-gpu's keys never reached the keymap; the cursor never moved. This contradicts the Phase B framing's "consumer-only" claim: the Explore fact-check verified `apply_event` → `dispatch_key` (true for grid frontends) but not that the dispatcher gates that call on `render_state`, so semantic-frontend keys never reach `apply_event`. Exactly the gap visual validation exists to catch. Fix: when the source has no `render_state` but is a registered semantic session, route its input through a new `apply_semantic_input_event` — `Key` → `dispatch_key`, `Mouse` → `dispatch_mouse` — the same core path the TUI uses. No grid state is needed (the editor core owns the cursor/buffer/commands); the resulting motion/edit flows back to pmacs-gpu as `CursorByte` / `CrdtOp`. Regression test `semantic_frontend_key_event_reaches_the_core`: a printable `Key` from a semantic frontend self-inserts and advances its window cursor (0→1). Before the fix the dispatcher dropped it. Gates green: - cargo fmt --all -- --check - cargo clippy --all-targets --workspace -- -D warnings (default + crdt) - pmacs lib 1334; crdt daemon tests pass - m4_acceptance 88, m11_5_semantic_acceptance (--features crdt) 2 Still awaiting visual confirmation (caret tracks arrow keys in a running pmacs-gpu) before merge, per the framing's process rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/daemon.rs | 96 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 1f928a0..43d8eea 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1354,15 +1354,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" ); @@ -1897,6 +1904,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 +2131,55 @@ 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)" + ); + } } From 4cd968bc0c4553a4937c3d834082b5abdf4b2deb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 30 May 2026 09:49:53 -0400 Subject: [PATCH 03/19] =?UTF-8?q?B1=20fix=20=E2=80=94=20align=20semantic?= =?UTF-8?q?=20frontend's=20window=20to=20its=20displayed=20buffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the "arrow keys do nothing in the GUI" investigation. Root cause is the multi-buffer mismatch the manual investigation theorized, now confirmed in code and tested: - `build_fresh_frontend_view` binds an attaching frontend's window to LOCAL's active buffer (a scratch the TUI never switched LOCAL away from). - `send_buffer_snapshots` ships a snapshot per buffer in registry order; pmacs-gpu treats each as "switch visible buffer", so its `current_buffer_id` (and what it displays) becomes the LAST one — the file the TUI opened. - So the GUI displays the file, but its daemon-side window edits the scratch. Arrow keys → `dispatch_key` → move the scratch cursor → `CursorByte { buffer_id: scratch }` → pmacs-gpu ignores it (its `current_buffer_id` is the file). The caret never tracks. Fix: the `Viewport` event already declares which buffer the frontend is displaying. The daemon now calls `align_semantic_window_to_buffer` on it — re-pointing the semantic frontend's window at the declared buffer (rebuild the cheap `TextView` line index, reset cursor; a semantic frontend has no grid overlays to migrate, it renders from the wire). Input and the `CursorByte` it produces then target the buffer the user is actually looking at. The guard makes it a no-op when the buffer is unchanged (so per-edit Viewport re-declarations don't reset the cursor). Tests: - `viewport_aligns_semantic_window_to_displayed_buffer` — window starts on scratch, declares the file via align, a key then self-inserts into the *file*. - `semantic_frontend_key_event_reaches_the_core` (from the prior commit) still green. Also adds `PMACS_GPU_DEBUG_INPUT=1`: logs keys sent and each `CursorByte` with `buf`/`current`/`match` so the displayed-vs-edited buffer alignment is visible at a glance on retest. Gates green: fmt; clippy --all-targets --workspace -D warnings (default + crdt); pmacs lib 1334; crdt daemon tests 7; pmacs-gpu unit 18; m4_acceptance 88; m11_5_semantic_acceptance 2. Still needs visual confirmation (arrow keys move the caret in a running pmacs-gpu) before merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 27 ++++++++- src/daemon.rs | 132 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 76d0c29..a724eea 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -378,9 +378,13 @@ impl ApplicationHandler for App { if let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers) && is_motion_key(pkey) && let Some(client) = self.attach_client.as_ref() - && let Err(e) = client.send_key(pkey, pmods) { - eprintln!("pmacs-gpu: send_key failed: {e}"); + if debug_input() { + eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}"); + } + if let Err(e) = client.send_key(pkey, pmods) { + eprintln!("pmacs-gpu: send_key failed: {e}"); + } } } WindowEvent::Resized(size) => { @@ -846,6 +850,14 @@ impl State { buffer_id, byte_pos, } => { + if debug_input() { + eprintln!( + "pmacs-gpu cursor: buf={buffer_id:?} byte={byte_pos} \ + current={:?} match={}", + self.current_buffer_id, + self.current_buffer_id == Some(buffer_id) + ); + } self.own_cursor = Some(OwnCursor { buffer_id, byte: byte_pos, @@ -1711,6 +1723,17 @@ fn debug_frame() -> bool { *FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_FRAME").is_some()) } +/// One-shot env flag: `PMACS_GPU_DEBUG_INPUT=1` logs the input path — +/// keys sent and `CursorByte` received (with the buffer it targets vs +/// the buffer being displayed). The buffer comparison is the B1 +/// diagnostic: if `CursorByte` targets a different buffer than +/// `current`, the caret won't track (the displayed/edited buffers are +/// out of sync). +fn debug_input() -> bool { + static FLAG: std::sync::OnceLock = std::sync::OnceLock::new(); + *FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_INPUT").is_some()) +} + /// Translate a winit logical key + current modifier state into a /// protocol `(Key, Modifiers)`. Returns `None` for keys the protocol /// has no representation for (the daemon ignores `Key::Unknown`, so diff --git a/src/daemon.rs b/src/daemon.rs index 43d8eea..a1a0a93 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1246,6 +1246,7 @@ fn handle_session_established( } #[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_lines)] // per-variant dispatcher match. fn handle_dispatcher_event( event: DispatcherEvent, editor: &mut EditorState, @@ -1342,8 +1343,20 @@ fn handle_dispatcher_event( // session never sends this; if one does, there is // no `SemanticRenderState` to update and it is a // benign no-op. - if let Some(sem) = semantic_states.get_mut(&source) { - sem.set_viewport(buffer_id, visible, generation); + if semantic_states.contains_key(&source) { + // Phase B (B1) — the Viewport declares *which + // buffer this frontend is displaying*. Align its + // editor window to that buffer so keyboard input + // (`dispatch_key`) and the `CursorByte` it emits + // target the displayed buffer. Without this, a + // semantic frontend's window stays bound to + // LOCAL's attach-time buffer (often a scratch the + // user isn't viewing), so arrow keys moved an + // off-screen cursor and the caret never tracked. + align_semantic_window_to_buffer(editor, source, buffer_id); + if let Some(sem) = semantic_states.get_mut(&source) { + sem.set_viewport(buffer_id, visible, generation); + } } } _ => { @@ -1843,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}; @@ -2182,4 +2242,72 @@ mod tests { (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" + ); + } } From da9a63aa10e1f0b98f153baf8082339902ef7f5a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 30 May 2026 10:06:59 -0400 Subject: [PATCH 04/19] =?UTF-8?q?B1=20polish=20=E2=80=94=20fix=20cursor-mo?= =?UTF-8?q?tion=20slowness=20+=20whole-line=20"selection"=20look?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from visual validation now that arrow keys work: 1. Far too slow. The `Decorations` arm called `self.reshape()` (set_rich_text + shape_until_scroll — a full text re-shape) on *every* decoration change. B1's own-window `CurrentLine` decoration changes on every up/down move, so each vertical cursor step forced a full re-shape. But only diagnostic decorations affect the rich text (they override glyph fg in `projected_rich_chunks`); Selection / CurrentLine / search are background quads rebuilt cheaply in `render()`. Now reshape runs only when the fg-affecting set changed (`fg_decoration_fingerprint` compares before/after); a background-only change just requests a redraw. 2. The entire line looked selected. The own-window `CurrentLine` wash paints the whole cursor line, which reads as a persistent selection — unwanted as default. The caret already marks the own cursor, so `collect_own_decoration_rects` now skips `CurrentLine` (renders only own `Selection`). Revises Q#B4: the caret is the own-cursor indicator, not a line wash. Peer presence still shows other frontends' lines. Test `fg_fingerprint_ignores_background_decoration_changes`: a CurrentLine-only change leaves the fingerprint equal (no reshape); a diagnostic change alters it (reshape). Gates green: fmt; clippy --all-targets --workspace -D warnings; pmacs-gpu unit 19 (+1). pmacs lib / daemon untouched. Deferred (noted for follow-up sessions, not B1): - Mouse click → cursor: needs the Q#B5 wire decision (no FrontendEvent::SetCursor variant; semantic frontends can't use grid-cell Mouse coords). Its own session. - PageUp/PageDown: keys are forwarded and move the daemon cursor, but pmacs-gpu renders from the top with no scroll, so the caret would leave the viewport. Needs GPU scrolling first. Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 79 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a724eea..56e3d12 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -782,12 +782,26 @@ impl State { if self.current_buffer_id != Some(buffer_id) { return None; } + // Only diagnostic decorations affect the *rich text* + // (they override glyph fg in `projected_rich_chunks`); + // background kinds (Selection / CurrentLine / Search) + // are quads rebuilt cheaply in `render()`. A full + // `reshape()` (set_rich_text + shape_until_scroll) on + // every decoration change made cursor motion crawl — + // B1's own `CurrentLine` changes on every up/down move. + // Reshape only when the fg-affecting set changed; else + // just repaint the quads. + let fg_before = fg_decoration_fingerprint(&self.current_decorations); if full { self.replace_decorations(segments); } else { self.merge_decorations(segments); } - self.reshape(); + if fg_before == fg_decoration_fingerprint(&self.current_decorations) { + self.window.request_redraw(); + } else { + self.reshape(); + } None } InstanceMessage::InlineAdornments { buffer_id, items } => { @@ -1286,12 +1300,18 @@ impl State { rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } - /// Own-window `Selection` / `CurrentLine` washes from - /// `current_decorations` (Q#B4). The producer emits these for this - /// frontend's window; they become non-trivial once B1's cursor - /// motion moves the window cursor off byte 0. + /// Own-window `Selection` washes from `current_decorations`. The + /// caret already marks the own cursor, so the own *`CurrentLine`* + /// wash is deliberately NOT rendered — a whole-line highlight on + /// every cursor line reads as a persistent selection, which is not + /// wanted as default editor behavior (revising Q#B4: the caret is + /// the own-cursor indicator; the line wash isn't). Peer presence + /// still shows other frontends' lines via `collect_peer_rects`. fn collect_own_decoration_rects(&self, rects: &mut Vec, line_offsets: &[u64]) { for d in &self.current_decorations { + if d.kind == DecorationKind::CurrentLine { + continue; + } if let Some(color) = decoration_kind_to_bg_color(d.kind) { self.push_glyph_extent_rects( rects, @@ -2041,6 +2061,21 @@ fn indexed_to_glyphon(idx: u8) -> glyphon::Color { glyphon::Color::rgb(level, level, level) } +/// The decorations that affect the *rich text* (a glyph fg override in +/// `projected_rich_chunks`), as an ordered `(range, kind)` set. Only +/// kinds with a foreground color qualify — i.e. the diagnostic +/// severities; background kinds (`Selection` / `CurrentLine` / search) +/// are quads. Equal fingerprints across a `Decorations` update mean the +/// shaped text is unaffected and a `reshape()` can be skipped (the perf +/// fix for cursor-motion-driven `CurrentLine` churn). +fn fg_decoration_fingerprint(decos: &[Decoration]) -> Vec<(ByteRange, DecorationKind)> { + decos + .iter() + .filter(|d| decoration_kind_to_color(d.kind).is_some()) + .map(|d| (d.range, d.kind)) + .collect() +} + /// Map a [`DecorationKind`] to a foreground color override, or `None` /// for kinds whose visual is a background and can't be expressed in /// the current `Attrs`-only rendering pipeline. @@ -2227,6 +2262,40 @@ mod tests { assert!(!m.contains(Modifiers::SHIFT)); } + #[test] + fn fg_fingerprint_ignores_background_decoration_changes() { + let deco = |start, end, kind| Decoration { + range: ByteRange { start, end }, + kind, + }; + // A diagnostic (fg) decoration + a CurrentLine (bg) decoration. + let before = vec![ + deco(10, 14, DecorationKind::DiagnosticError), + deco(0, 20, DecorationKind::CurrentLine), + ]; + // The cursor moved: CurrentLine now spans a different line, the + // diagnostic is unchanged. + let after = vec![ + deco(10, 14, DecorationKind::DiagnosticError), + deco(40, 60, DecorationKind::CurrentLine), + ]; + assert_eq!( + fg_decoration_fingerprint(&before), + fg_decoration_fingerprint(&after), + "a CurrentLine-only change must not change the fg fingerprint (no reshape)" + ); + + // A diagnostic change DOES alter the fingerprint (reshape needed). + let after_diag = vec![ + deco(10, 18, DecorationKind::DiagnosticError), + deco(0, 20, DecorationKind::CurrentLine), + ]; + assert_ne!( + fg_decoration_fingerprint(&before), + fg_decoration_fingerprint(&after_diag) + ); + } + #[test] fn line_byte_offsets_indexes_each_logical_line() { // "abc\nde\nfgh": lines start at bytes 0, 4, 7. Indexed by From 5888ced15a784aedbb418976fbd6f000459c8080 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 30 May 2026 10:13:43 -0400 Subject: [PATCH 05/19] =?UTF-8?q?session=20B2=20=E2=80=94=20text=20editing?= =?UTF-8?q?=20in=20pmacs-gpu=20(Key=20round-trip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broadens the send gate from motion-only (B1) to plain text editing: `should_forward_key` forwards Char / Backspace / Enter / Delete / Tab in addition to motion keys. Editing rides the same round trip B1 proved — the daemon's `dispatch_key` self-inserts / deletes on the viewport-aligned buffer, authors the CRDT op (`CrdtOpOrigin::DaemonKey`, excluded from no recipient), and broadcasts it back; pmacs-gpu applies it (existing session-3 CrdtOp path) and the edit also propagates to the TUI. No editing logic in the frontend. Ctrl/Alt/Meta chords are deliberately withheld: they drive commands and minibuffer flows the GUI can't render or interact with yet (the minibuffer is instance-side global state; a GUI frontend opening one with no way to see/cancel it would wedge input). Those land in a later command-parity session with GUI minibuffer rendering. Shift is not a chord modifier — Shift+a already arrives as `Char('A')`. Test `should_forward_key_gates_editing_keys_and_excludes_chords`: editing keys + uppercase forward; Ctrl/Alt + char withheld; motion keys forward regardless of modifiers. Gates green: fmt; clippy --all-targets --workspace -D warnings; pmacs-gpu unit 20 (+1). Daemon/lib untouched (it already dispatches semantic-frontend keys, B1 fix). Awaiting visual confirmation: typing in pmacs-gpu inserts text that propagates to the TUI; backspace/enter/delete work; CRDT stays converged. Per the framing's process rule, not merged until confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 74 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 56e3d12..282e50f 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -371,12 +371,13 @@ impl ApplicationHandler for App { event_loop.exit(); return; } - // Session B1 forwards cursor-motion keys only; editing - // keys (chars, Backspace, Enter, Delete) open in B2. - // `translate_key` handles the full set so B2 just drops - // the `is_motion_key` gate. + // Session B2 forwards cursor motion + plain text editing + // (Char / Backspace / Enter / Delete / Tab). Ctrl/Alt/ + // Meta chords are withheld — they drive commands and + // minibuffer flows the GUI can't render or interact with + // yet (a later session adds GUI minibuffer + chords). if let Some((pkey, pmods)) = translate_key(&key.logical_key, self.modifiers) - && is_motion_key(pkey) + && should_forward_key(pkey, pmods) && let Some(client) = self.attach_client.as_ref() { if debug_input() { @@ -1801,9 +1802,9 @@ fn translate_key( Some((pkey, pmods)) } -/// Session B1 forwards cursor-motion keys only; editing keys wait for -/// B2. Keeping the gate as a single predicate means B2 removes one -/// call site, not a translation rewrite. +/// Cursor-motion keys — forwarded with any modifier set (e.g. `C-Left` +/// is word-motion, `S-Down` extends a selection; the daemon's keymap +/// decides). fn is_motion_key(key: ProtocolKey) -> bool { matches!( key, @@ -1818,6 +1819,33 @@ fn is_motion_key(key: ProtocolKey) -> bool { ) } +/// Whether to forward a translated key to the daemon (session B2). +/// Motion keys go through with any modifiers. Plain text-editing keys +/// (`Char` / `Backspace` / `Enter` / `Delete` / `Tab`) go through only +/// *without* a Ctrl/Alt/Meta chord modifier: a bare key edits text, +/// but a chord drives commands and minibuffer flows the GUI can't +/// render or interact with yet (deferred to a later session). Shift is +/// not a chord modifier — `Shift`+a already arrives as `Char('A')`. +fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { + if is_motion_key(key) { + return true; + } + let chord = mods.contains(Modifiers::CTRL) + || mods.contains(Modifiers::ALT) + || mods.contains(Modifiers::META); + if chord { + return false; + } + matches!( + key, + ProtocolKey::Char(_) + | ProtocolKey::Backspace + | ProtocolKey::Enter + | ProtocolKey::Delete + | ProtocolKey::Tab + ) +} + /// Buffer-absolute byte offset of the start of each `\n`-delimited /// line (index 0 = byte 0). Indexed by cosmic-text's /// `LayoutRun::line_i` to rebase line-relative glyph offsets. @@ -2251,6 +2279,36 @@ mod tests { assert!(!is_motion_key(bk)); } + #[test] + fn should_forward_key_gates_editing_keys_and_excludes_chords() { + let none = Modifiers::NONE; + let ctrl = Modifiers::CTRL; + let shift = Modifiers::SHIFT; + + // Plain text-editing keys forward. + for key in [ + ProtocolKey::Char('a'), + ProtocolKey::Char('A'), + ProtocolKey::Backspace, + ProtocolKey::Enter, + ProtocolKey::Delete, + ProtocolKey::Tab, + ] { + assert!(should_forward_key(key, none), "{key:?} should forward"); + } + // Shift is not a chord modifier (Shift+a already arrives as 'A'). + assert!(should_forward_key(ProtocolKey::Char('A'), shift)); + + // Ctrl/Alt/Meta + a non-motion key is a chord — withheld in B2. + assert!(!should_forward_key(ProtocolKey::Char('x'), ctrl)); + assert!(!should_forward_key(ProtocolKey::Char('f'), Modifiers::ALT)); + + // Motion keys forward regardless of modifiers (C-Left = word-left). + assert!(should_forward_key(ProtocolKey::Left, ctrl)); + assert!(should_forward_key(ProtocolKey::Down, shift)); + assert!(should_forward_key(ProtocolKey::PageUp, none)); + } + #[test] fn translate_key_carries_modifiers() { use winit::keyboard::{Key as WKey, ModifiersState, NamedKey}; From 0f5f4d17692e8150507b0c96af707079f0c2ab3a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 30 May 2026 10:21:09 -0400 Subject: [PATCH 06/19] =?UTF-8?q?B2=20fix=20=E2=80=94=20clamp=20rich-text?= =?UTF-8?q?=20slice=20boundaries=20to=20char=20boundaries=20(crash)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in a large file crashed: "byte index N is not a char boundary; it is inside '→'". `projected_rich_chunks` slices `current_text` at span / decoration / adornment byte offsets, but those offsets come from the daemon for a possibly-earlier generation than the rope this frame holds (the one-frame edit race). After an edit a stale offset can land inside a multi-byte codepoint, panicking `text[a..b]`. Snap every boundary to the previous UTF-8 char boundary before slicing (new stable `floor_char_boundary` helper; the older `style_runs_for_text` path already did the equivalent `is_char_boundary` guard — this newer adornment-aware path was missing it). Flooring only shifts a chunk edge left to the start of the codepoint it fell inside; chunks still reassemble the original text. Tests: `projected_rich_chunks_tolerates_mid_codepoint_boundaries` (span ending mid-'→' + a past-end diagnostic; chunks reassemble the text) and `floor_char_boundary_snaps_into_multibyte_char`. Gates: fmt; clippy --all-targets --workspace -D warnings; pmacs-gpu unit 22 (+2). NOTE: this fixes the crash, not the large-file slowness — that is the whole-file reshape architecture (projected_rich_chunks + set_rich_text are O(file), run per edit, and the daemon runs a whole-file tree-sitter highlight query per edit). Making large-file editing usable needs viewport-scoped rendering + scrolling, scoped on both the GPU and the producer. That is its own session. Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 64 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 282e50f..ca30a4c 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1846,6 +1846,21 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { ) } +/// Largest char-boundary `<= index` (stable equivalent of the unstable +/// `str::floor_char_boundary`). Used to snap externally-supplied byte +/// offsets to valid slice points so a stale, mid-codepoint offset can't +/// panic a `text[..]` slice. +fn floor_char_boundary(text: &str, index: usize) -> usize { + if index >= text.len() { + return text.len(); + } + let mut i = index; + while i > 0 && !text.is_char_boundary(i) { + i -= 1; + } + i +} + /// Buffer-absolute byte offset of the start of each `\n`-delimited /// line (index 0 = byte 0). Indexed by cosmic-text's /// `LayoutRun::line_i` to rebase line-relative glyph offsets. @@ -1925,14 +1940,22 @@ fn projected_rich_chunks( adornments: &[InlineAdornment], ) -> Vec { let text_len = text.len() as u64; + // Every boundary used to slice `text` must be snapped to a UTF-8 + // char boundary. Span / decoration / adornment offsets come from + // the daemon for a possibly-earlier generation than the rope this + // frame holds (the one-frame edit race), so a raw offset can land + // inside a multi-byte char and panic the slice. Flooring to the + // previous char boundary is safe: it only shifts a chunk edge left + // to the start of the codepoint it fell inside. + let snap = |b: u64| floor_char_boundary(text, b.min(text_len) as usize) as u64; let mut boundaries: Vec = vec![0, text_len]; for sp in spans { - boundaries.push(sp.range.start.min(text_len)); - boundaries.push(sp.range.end.min(text_len)); + boundaries.push(snap(sp.range.start)); + boundaries.push(snap(sp.range.end)); } for d in decorations { - boundaries.push(d.range.start.min(text_len)); - boundaries.push(d.range.end.min(text_len)); + boundaries.push(snap(d.range.start)); + boundaries.push(snap(d.range.end)); } let mut renderable_adornments: Vec<(usize, u64, &InlineAdornment)> = adornments .iter() @@ -1940,7 +1963,7 @@ fn projected_rich_chunks( .filter_map(|(idx, a)| renderable_adornment_anchor(a, text_len).map(|at| (idx, at, a))) .collect(); for (_, at, _) in &renderable_adornments { - boundaries.push(*at); + boundaries.push(snap(*at)); } boundaries.sort_unstable(); boundaries.dedup(); @@ -2429,6 +2452,37 @@ mod tests { } } + #[test] + fn projected_rich_chunks_tolerates_mid_codepoint_boundaries() { + // Stale span offsets (from a prior generation) can land inside a + // multi-byte char after an edit. "ab→cd": '→' is the 3 bytes + // [2,5); a span ending at byte 3 is mid-codepoint and must not + // panic the slice — it floors to the char start. + let text = "ab→cd"; + let chunks = projected_rich_chunks( + text, + &[span(0, 3, CellColor::Indexed(1))], + &[Decoration { + range: ByteRange { start: 4, end: 9 }, + kind: DecorationKind::DiagnosticError, + }], + &[], + ); + let rendered: String = chunks.iter().map(|chunk| chunk.text.as_str()).collect(); + assert_eq!(rendered, text, "chunks must reassemble the original text"); + } + + #[test] + fn floor_char_boundary_snaps_into_multibyte_char() { + let text = "ab→cd"; // '→' = bytes [2,5) + assert_eq!(floor_char_boundary(text, 0), 0); + assert_eq!(floor_char_boundary(text, 2), 2); + assert_eq!(floor_char_boundary(text, 3), 2); // inside '→' → floor to 2 + assert_eq!(floor_char_boundary(text, 4), 2); + assert_eq!(floor_char_boundary(text, 5), 5); + assert_eq!(floor_char_boundary(text, 99), text.len()); + } + #[test] fn projected_rich_chunks_inserts_at_offset_without_source_bytes() { let chunks = projected_rich_chunks( From 5762171831c1f2a760011cd55b8a0ebbf4dc01d2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 30 May 2026 10:28:59 -0400 Subject: [PATCH 07/19] docs: viewport-scoped rendering + scroll framing (perf) Frames the fix for unusable large-file editing in pmacs-gpu: render only the visible byte slice (O(visible) not O(file)) + line-based scroll. Stance: feed cosmic-text only current_text[vstart..vend] (the native Scroll path only makes shaping lazy, not set_rich_text / projected_rich_chunks, which dominate). Q-decisions: line-based scroll, caret-follow auto-scroll, small overscan, rebase-by-vstart, scoped Viewport declaration; daemon whole-file highlight query deferred (Q#S6). Bet S2 (coordinate-space rebasing) flagged as the QB3-class risk. Fact-checked: all load-bearing claims hold (GPU declares whole-file viewport; reshape is O(file); producer already clips spans to vp.visible; cosmic-text splits BufferLines on \n so slices must be line-aligned; caret/wash builders use whole-file offsets). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/pmacs-gpu-scroll-framing.md | 164 +++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/pmacs-gpu-scroll-framing.md diff --git a/docs/pmacs-gpu-scroll-framing.md b/docs/pmacs-gpu-scroll-framing.md new file mode 100644 index 0000000..2fd3e69 --- /dev/null +++ b/docs/pmacs-gpu-scroll-framing.md @@ -0,0 +1,164 @@ +# pmacs-gpu — viewport-scoped rendering & scroll (framing) + +**Status: framing pass; pre-implementation. Urgent.** Editing a large +file in pmacs-gpu is unusably slow because rendering is O(file) per +keystroke. This milestone makes it O(visible) and adds scrolling. Same +framing discipline as the Phase B framing: Q-decisions committed before +code, fact-checked first. + +## Why this exists (the perf problem, precisely) + +Per keystroke today, for a 240 KB file: + +- **GPU**: `reshape()` rebuilds rich text from the *entire* + `current_text` — `projected_rich_chunks` walks all 240 KB, then + `set_rich_text` creates a `BufferLine` per source line (~25k). It + fires on every edit, and one keystroke produces 2–3 reshapes (the + `CrdtOp`, `StyleSpans`, and `Decorations` frames each trigger one). +- **GPU Viewport**: pmacs-gpu declares `visible: { 0, text_len }` — the + whole file (`main.rs` `BufferSnapshot`/`CrdtOp` arms). So the producer + is asked to style the whole file. +- **Daemon**: on each edit the generation bumps, the `StyleGate` + recomputes, and `scoped_style_spans` runs the tree-sitter highlight + query over the whole bundle (it clips the *result* to the viewport + but runs the query whole-file). + +The GPU's O(file) `set_rich_text` + `projected_rich_chunks` dominate and +run per edit. Editing is hundreds of ms to seconds per character. + +## Goal + +Make the GPU per-edit cost **O(visible lines)**, not O(file), and add +line-based scrolling so the whole file is reachable. Correctness (text, +styling, caret, edits, CRDT convergence) is preserved. + +## Approach: render only the visible slice (stance committed) + +cosmic-text offers a native `Buffer::set_scroll` + `shape_until_scroll` +path, but that only makes *shaping* lazy — `set_rich_text` and +`projected_rich_chunks` would still process the whole rope. So that path +does **not** fix the dominant cost. + +**Stance: feed cosmic-text only the visible byte slice.** The GPU keeps +the whole rope in `current_text` (the CRDT replica is authoritative and +small to hold), but builds chunks and calls `set_rich_text` over +`current_text[vstart..vend]` — the byte range of the visible source +lines. Everything cosmic-text touches is then O(visible): chunk build, +`BufferLine` creation, shaping, layout. Scrolling re-slices and +re-shapes; the buffer always renders from its own top (no cosmic-text +scroll offset needed). + +The cost is **coordinate rebasing**: spans / decorations / caret / +presence arrive in whole-file byte coordinates; the slice starts at +file byte `vstart`, so each offset maps to slice byte `offset - vstart`, +and only the portion intersecting `[vstart, vend)` is rendered. This is +the part to get exactly right — and the QB3 lesson applies (glyph +offsets are line-relative; `line_byte_offsets` is computed on the +slice). + +## Contract inheritance + +Pixel-pure instance preserved: the GPU still sends only a byte-range +`Viewport` and byte-anchored input. The producer already clips +`StyleSpans` / `Decorations` to `vp.visible` (verified), so a scoped +viewport immediately cuts wire volume and GPU span processing with no +producer change required. + +## Forced decisions + +### Q#S1 — scroll unit: line-based + +**Scroll position is a source-line index (`scroll_top`), not a pixel +offset.** The visible slice is `[line_start(scroll_top), +line_start(scroll_top + visible_lines + overscan))`. Line-based scroll +makes the slice always start on a line boundary (cosmic-text splits +`BufferLine`s on `\n`, so a mid-line slice would corrupt the first +line) and matches how `estimated_visible_lines` already works. +Pixel-smooth scroll is a later refinement. + +### Q#S2 — what drives scroll: keep the caret visible + +**The cursor stays on screen.** `scroll_top` is adjusted whenever a +`CursorByte` (own cursor) would fall outside the visible line range: +scroll just enough to bring the cursor's line to the nearest visible +edge (+ a small margin). This is the only scroll trigger session-1 +needs — it makes arrow/PageUp/PageDown navigation work without a +separate scroll command: + +- Arrow up/down past the edge → cursor moves (daemon) → `CursorByte` + → auto-scroll follows. +- `PageUp`/`PageDown` → already forwarded; the daemon moves the cursor + by a page → `CursorByte` → auto-scroll follows. (No GPU-local page + math; the daemon owns cursor motion, Q#B3.) + +Mouse-wheel / scrollbar scroll-without-cursor-move is a later add. + +### Q#S3 — overscan: a small margin + +Slice a few lines beyond the visible region (e.g. visible + 2) so a +1-line scroll doesn't always re-slice, and the bottom partial line +renders. Keep it small — overscan is wasted shaping. + +### Q#S4 — rebasing rule: subtract `vstart`, computed once + +A single `vstart` (file byte of the first visible line) rebases +everything: `slice = current_text[vstart..vend]`; a span/decoration/ +caret at file byte `b` is at slice byte `b - vstart` and is rendered +only if `vstart <= b < vend`; `line_byte_offsets` is computed on +`slice`. One helper clips+rebases a `[start,end)` range to the slice +(returns `None` when disjoint). The caret and the background-wash +builders all route through it. + +### Q#S5 — Viewport declaration: the visible range + +The GPU declares `Viewport { visible: { vstart, vend } }` whenever the +slice changes (scroll or buffer switch), and **re-declares on scroll** +so the producer ships spans for what's on screen. Coalesce: only send +when `[vstart, vend)` actually changed. (Today only `BufferSnapshot` +declares a Viewport; the `CrdtOp` edit arm sends none — so the producer +keeps styling the last-declared range across edits, which is exactly +why the whole-file range is currently sticky.) + +### Q#S6 — daemon whole-file highlight query: out of scope (noted) + +`scoped_style_spans` runs the tree-sitter query over the whole bundle +even for a scoped viewport. After this milestone the GPU is O(visible) +and the wire is scoped, so the daemon query becomes the next bottleneck +on very large files — but it is a *separate* producer optimization +(query-by-range / incremental), deferred to its own task. The GPU +session does not depend on it. + +## Predicted findings — categorical bets + +| # | Bet | Category | +|---|---|---| +| S1 | Off-by-one / mid-line slicing at the bottom edge (partial last line, trailing newline) corrupts the visible text | Boundary-decomposition | +| S2 | Rebasing misses a coordinate space — a span or the caret rendered at `b` instead of `b - vstart`, or vice versa | Coordinate-space (recurrence of QB3) | +| S3 | Scroll re-declares Viewport every frame (not just on change), re-introducing per-frame churn | Cadence | +| S4 | Caret-follow scroll oscillates or lags by a frame when the cursor moves and the slice + caret update on different frames | Temporal-interaction | + +Bet S2 is pre-flagged: the rebasing must be verified against the +cosmic-text source as QB3 taught, not assumed. + +## Session plan + +| Session | Work | Probe | +|---|---|---| +| **S1** | Visible-slice `reshape` (slice + clip/rebase spans, decorations, caret, washes) + `scroll_top` state + caret-follow auto-scroll + scoped Viewport (Q#S1–S5). | Open a 240 KB file: editing is snappy; arrow/PageUp/PageDown navigate the whole file with the caret staying visible; styling/caret/edits correct at any scroll position; TUI stays converged. | +| **S2+** | Mouse-wheel / scrollbar scroll; pixel-smooth scroll; daemon query-by-range (Q#S6). | Per-session. | + +## Process rule (carried forward) + +Touches the core render path — the area that has regressed before. Not +merged until visually confirmed on a large file: editing is fast, the +caret tracks, and styling is correct **after scrolling** (the rebasing +is only exercised once `vstart > 0`). CI-green is necessary, not +sufficient. + +## Deliberately not committed + +- Pixel-smooth (sub-line) scroll — line-based first. +- Mouse-wheel / scrollbar — S2. +- Daemon query-by-range — Q#S6, its own task. +- Horizontal scroll / no-wrap long lines — separate; pmacs-gpu has no + soft wrap yet. From ecb0a862fc527a90a55583b596596c0da1d8772f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 30 May 2026 10:40:04 -0400 Subject: [PATCH 08/19] =?UTF-8?q?session=20S1=20=E2=80=94=20viewport-scope?= =?UTF-8?q?d=20rendering=20+=20scroll=20(large-file=20perf)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the O(file)-per-keystroke slowness that made large-file editing unusable. pmacs-gpu now shapes only the visible byte slice instead of the whole rope. Core change (`reshape`): compute the visible byte range from `scroll_top` + the window's visible line count (+ small overscan), slice `current_text[vstart..vend]`, clip+rebase spans / decorations / adornments onto the slice (subtract `vstart`), and feed only that to `set_rich_text`. cosmic-text now touches ~screenful of lines, not 25k. `set_rich_text` resets scroll to the slice top (verified), so the slice renders from y=0. Scroll (line-based, Q#S1): - `scroll_top` source-line state; `visible_byte_range` line-aligns the slice (cosmic-text splits BufferLines on `\n`). - `scroll_to_cursor` (Q#S2): on `CursorByte`, if the cursor leaves the visible window, scroll to follow, re-shape, and re-declare the scoped Viewport. PageUp/Down already forward → daemon moves the cursor → this follows. No GPU-local page math. - Scoped `Viewport` declaration (Q#S5) via `viewport_send_if_changed` (coalesced): on snapshot, scroll, edit (bytes shift), and resize. The producer already clips `StyleSpans`/`Decorations` to `vp.visible`, so it now styles only what's on screen — no producer change. Rebasing (bet S2, the QB3-class risk): one primitive, `clip_rebase_range`, clips a whole-file `[start,end)` to the slice and subtracts `vstart`, returning `None` when disjoint. Caret (`caret_rect`) and both wash collectors route through it; `line_offsets` are computed on the slice. Caret returns `None` when scrolled off-screen. Also resets `scroll_top` + `last_viewport_sent` on buffer switch. Tests: `clip_rebase_range_clips_to_slice_and_subtracts_vstart`. Gates: fmt; clippy --all-targets --workspace -D warnings; pmacs-gpu unit 23 (+1). Daemon/lib untouched. Per the framing's process rule, NOT merged until visually confirmed on a large file AND after scrolling (the rebasing is only exercised once vstart > 0): editing snappy; arrows/PageUp/PageDown navigate with the caret staying visible; styling + caret correct at any scroll position; TUI stays converged. Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 318 +++++++++++++++++++++++++++++++++++------- 1 file changed, 265 insertions(+), 53 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index ca30a4c..344c24e 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -70,6 +70,10 @@ const TEXT_TOP: f32 = 16.0; /// B1. const CARET_WIDTH: f32 = 2.0; const CARET_COLOR: [f32; 4] = [0.90, 0.90, 0.96, 0.90]; +/// Extra source lines shaped beyond the visible window so a 1-line +/// scroll doesn't always re-slice and the bottom partial line renders +/// (Q#S3). Kept small — overscan is wasted shaping. +const SCROLL_OVERSCAN: usize = 2; const TEXT_RIGHT_GAP: f32 = 10.0; const MINIMAP_WIDTH: f32 = 48.0; const MINIMAP_RIGHT: f32 = 12.0; @@ -296,6 +300,23 @@ struct State { /// from commands this frontend never interprets locally. `None` /// until the first `CursorByte`. own_cursor: Option, + /// Top visible *source line* (0-based). Scroll is line-based + /// (Q#S1). `reshape` shapes only the lines from here through the + /// visible window; `view_range` records the byte span actually fed + /// to cosmic-text so caret/wash byte offsets can be rebased onto + /// it. + scroll_top: usize, + /// Whole-file byte range `[vstart, vend)` of the slice the + /// cosmic-text `buffer` currently holds (session S1). Everything + /// the buffer renders is in slice coordinates (`file_byte - + /// vstart`); this is the rebasing origin for the caret and the + /// background washes. + view_range: (u64, u64), + /// Last `[vstart, vend)` declared to the daemon via a `Viewport` + /// event. Re-declared only when it changes (scroll, edit that + /// shifts visible bytes, buffer switch) so the producer scopes + /// `StyleSpans` to what's on screen without per-frame churn (Q#S5). + last_viewport_sent: Option<(u64, u64)>, } /// pmacs-gpu's own cursor position, mirrored from `CursorByte`. @@ -389,8 +410,15 @@ impl ApplicationHandler for App { } } WindowEvent::Resized(size) => { - if let Some(state) = self.state.as_mut() { - state.resize(size.width.max(1), size.height.max(1)); + let vp = self + .state + .as_mut() + .and_then(|state| state.resize(size.width.max(1), size.height.max(1))); + if let Some(vp) = vp + && let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) + { + eprintln!("pmacs-gpu: resize send_viewport failed: {e}"); } } WindowEvent::RedrawRequested => { @@ -610,6 +638,9 @@ impl State { current_summary: None, peer_presences: HashMap::new(), own_cursor: None, + scroll_top: 0, + view_range: (0, 0), + last_viewport_sent: None, } } @@ -699,17 +730,15 @@ impl State { // next PresenceUpdate / CursorByte arrives. self.peer_presences.clear(); self.own_cursor = None; + // New buffer ⇒ back to the top, and force a viewport + // re-declaration for the new buffer's scoped range. + self.scroll_top = 0; + self.last_viewport_sent = None; + let _ = text_len; if !self.set_text(&text) { self.reshape(); } - Some(ViewportSend { - buffer_id, - visible: ByteRange { - start: 0, - end: text_len, - }, - generation: 0, - }) + self.viewport_send_if_changed(buffer_id) } InstanceMessage::CrdtOp { buffer_id, op } => { if self.current_buffer_id != Some(buffer_id) { @@ -755,7 +784,12 @@ impl State { // fresh `textDocument/inlayHint` response arrives. let text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); self.set_text(&text); - None + // An edit shifts byte positions, so the scoped viewport's + // byte range moves even at the same scroll position; + // re-declare it so the producer styles the right bytes + // (the generation bump already forces a full resync, so + // this is no extra round trip). + self.viewport_send_if_changed(buffer_id) } InstanceMessage::StyleSpans { buffer_id, @@ -877,6 +911,17 @@ impl State { buffer_id, byte: byte_pos, }); + // Session S1 — keep the caret on screen (Q#S2). When the + // cursor leaves the visible slice (arrows past an edge, + // PageUp/Down), scroll to follow it, re-shape the new + // slice, and re-declare the scoped Viewport so the + // producer ships spans for what's now visible. + if self.scroll_to_cursor() { + self.reshape(); + if let Some(vp) = self.viewport_send_if_changed(buffer_id) { + return Some(vp); + } + } self.window.request_redraw(); None } @@ -884,6 +929,49 @@ impl State { } } + /// A `ViewportSend` for the current `view_range` if it differs from + /// the last one declared, else `None` (Q#S5 coalescing). `generation` + /// is 0 — the producer's full-resync triggers on the visible-range + /// change and on the CRDT generation bump, not this field. + fn viewport_send_if_changed(&mut self, buffer_id: BufferId) -> Option { + if self.last_viewport_sent == Some(self.view_range) { + return None; + } + self.last_viewport_sent = Some(self.view_range); + let (start, end) = self.view_range; + Some(ViewportSend { + buffer_id, + visible: ByteRange { start, end }, + generation: 0, + }) + } + + /// Adjust `scroll_top` so the own cursor's source line is within the + /// visible window (Q#S2). Returns whether `scroll_top` changed (in + /// which case the caller re-shapes + re-declares the viewport). + fn scroll_to_cursor(&mut self) -> bool { + let Some(own) = self.own_cursor else { + return false; + }; + if self.current_buffer_id != Some(own.buffer_id) { + return false; + } + let line_starts = line_byte_offsets(&self.current_text); + let cursor = own.byte.min(self.current_text.len() as u64); + // Cursor's source line = largest i with line_starts[i] <= cursor. + let cursor_line = line_starts + .partition_point(|&s| s <= cursor) + .saturating_sub(1); + let visible = estimated_visible_lines(self.config.height).max(1); + let old = self.scroll_top; + if cursor_line < self.scroll_top { + self.scroll_top = cursor_line; + } else if cursor_line >= self.scroll_top + visible { + self.scroll_top = cursor_line + 1 - visible; + } + self.scroll_top != old + } + fn apply_file_style_summary( &mut self, buffer_id: BufferId, @@ -1063,23 +1151,84 @@ impl State { /// this is bounded by visible bytes. A sweep-line refactor with /// active-set pointers is the obvious upgrade if reshape cost /// surfaces in profile data — recorded but not done in session 5. + /// Whole-file byte range `[vstart, vend)` of the source lines that + /// should be shaped: from `scroll_top` through the visible window + /// plus a small overscan (Q#S1/S3). Both ends fall on line + /// boundaries (cosmic-text splits `BufferLine`s on `\n`, so a + /// mid-line slice would corrupt the first/last line). + fn visible_byte_range(&self) -> (u64, u64) { + let line_starts = line_byte_offsets(&self.current_text); + let n = line_starts.len(); + let top = self.scroll_top.min(n.saturating_sub(1)); + let span = estimated_visible_lines(self.config.height).max(1) + SCROLL_OVERSCAN; + let vstart = line_starts[top]; + let bottom = top.saturating_add(span).min(n); + let vend = if bottom < n { + line_starts[bottom] + } else { + self.current_text.len() as u64 + }; + (vstart, vend) + } + fn reshape(&mut self) { + // Session S1 — shape only the visible byte slice. Feeding the + // whole rope to `set_rich_text` (a BufferLine per source line) + // made large-file editing O(file) per keystroke; cosmic-text + // touches only `current_text[vstart..vend]` now. Spans / + // decorations / adornments arrive in whole-file coordinates and + // are clipped + rebased onto the slice (subtract `vstart`). + let (vstart, vend) = self.visible_byte_range(); + self.view_range = (vstart, vend); + let slice = &self.current_text[vstart as usize..vend as usize]; + + let spans: Vec = self + .current_spans + .iter() + .filter_map(|sp| { + clip_rebase_range(sp.range.start, sp.range.end, vstart, vend).map(|(s, e)| { + StyleSpan { + range: ByteRange { start: s, end: e }, + style: sp.style, + } + }) + }) + .collect(); + let decorations: Vec = self + .current_decorations + .iter() + .filter_map(|d| { + clip_rebase_range(d.range.start, d.range.end, vstart, vend).map(|(s, e)| { + Decoration { + range: ByteRange { start: s, end: e }, + kind: d.kind, + } + }) + }) + .collect(); + let adornments: Vec = self + .current_adornments + .iter() + .filter(|a| a.at >= vstart && a.at <= vend) + .map(|a| { + let mut a = a.clone(); + a.at -= vstart; + a + }) + .collect(); + let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono")); - let chunks: Vec<(String, Attrs<'static>)> = projected_rich_chunks( - &self.current_text, - &self.current_spans, - &self.current_decorations, - &self.current_adornments, - ) - .into_iter() - .map(|chunk| { - let mut attrs = default_attrs.clone(); - if let Some(c) = chunk.color { - attrs = attrs.color(c); - } - (chunk.text, attrs) - }) - .collect(); + let chunks: Vec<(String, Attrs<'static>)> = + projected_rich_chunks(slice, &spans, &decorations, &adornments) + .into_iter() + .map(|chunk| { + let mut attrs = default_attrs.clone(); + if let Some(c) = chunk.color { + attrs = attrs.color(c); + } + (chunk.text, attrs) + }) + .collect(); self.buffer.set_rich_text( &mut self.font_system, chunks.iter().map(|(s, a)| (s.as_str(), a.clone())), @@ -1091,7 +1240,7 @@ impl State { self.window.request_redraw(); } - fn resize(&mut self, width: u32, height: u32) { + fn resize(&mut self, width: u32, height: u32) -> Option { self.config.width = width; self.config.height = height; self.surface.configure(&self.device, &self.config); @@ -1102,7 +1251,12 @@ impl State { Some(width as f32), Some(height as f32), ); + // A taller/shorter window changes the visible line count, so the + // slice + scoped viewport change (session S1). + self.reshape(); self.window.request_redraw(); + self.current_buffer_id + .and_then(|bid| self.viewport_send_if_changed(bid)) } #[allow(clippy::too_many_lines)] // linear per-frame GPU sequence + optional timing. @@ -1294,10 +1448,18 @@ impl State { let Some(buffer_id) = self.current_buffer_id else { return Vec::new(); }; - let line_offsets = line_byte_offsets(&self.current_text); + let (vstart, vend) = self.view_range; + if vend <= vstart { + return Vec::new(); + } + // Glyph offsets are relative to the *slice* the buffer holds + // (session S1), so the line table is computed on the slice and + // every whole-file byte range is clip-rebased onto it. + let slice = &self.current_text[vstart as usize..vend as usize]; + let line_offsets = line_byte_offsets(slice); let mut rects = Vec::new(); - self.collect_own_decoration_rects(&mut rects, &line_offsets); - self.collect_peer_rects(buffer_id, &line_offsets, &mut rects); + self.collect_own_decoration_rects(&mut rects, &line_offsets, vstart, vend); + self.collect_peer_rects(buffer_id, &line_offsets, vstart, vend, &mut rects); rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } @@ -1308,19 +1470,21 @@ impl State { /// wanted as default editor behavior (revising Q#B4: the caret is /// the own-cursor indicator; the line wash isn't). Peer presence /// still shows other frontends' lines via `collect_peer_rects`. - fn collect_own_decoration_rects(&self, rects: &mut Vec, line_offsets: &[u64]) { + fn collect_own_decoration_rects( + &self, + rects: &mut Vec, + line_offsets: &[u64], + vstart: u64, + vend: u64, + ) { for d in &self.current_decorations { if d.kind == DecorationKind::CurrentLine { continue; } - if let Some(color) = decoration_kind_to_bg_color(d.kind) { - self.push_glyph_extent_rects( - rects, - line_offsets, - d.range.start, - d.range.end, - color, - ); + if let Some(color) = decoration_kind_to_bg_color(d.kind) + && let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) + { + self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); } } } @@ -1332,6 +1496,8 @@ impl State { &self, buffer_id: BufferId, line_offsets: &[u64], + vstart: u64, + vend: u64, rects: &mut Vec, ) { let text_len = self.current_text.len() as u64; @@ -1341,14 +1507,16 @@ impl State { } if let Some(color) = decoration_kind_to_bg_color(DecorationKind::CurrentLine) { let (lo, hi) = source_line_range(&self.current_text, presence.cursor); - self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); + if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) { + self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); + } } if let Some(sel) = presence.selection && let Some(color) = decoration_kind_to_bg_color(DecorationKind::Selection) { let lo = sel.anchor.min(sel.active).min(text_len); let hi = sel.anchor.max(sel.active).min(text_len); - if hi > lo { + if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) { self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color); } } @@ -1356,27 +1524,43 @@ impl State { } /// Vertex bytes for the caret quad, drawn *over* the text (B1). - /// Empty when no own cursor is known or it's in another buffer. + /// Empty when no own cursor is known, it's in another buffer, or it + /// is scrolled out of the visible slice. fn caret_vertex_bytes(&self) -> Vec { - let line_offsets = line_byte_offsets(&self.current_text); - let Some(rect) = self.caret_rect(&line_offsets) else { + let (vstart, vend) = self.view_range; + if vend <= vstart { + return Vec::new(); + } + let slice = &self.current_text[vstart as usize..vend as usize]; + let line_offsets = line_byte_offsets(slice); + let Some(rect) = self.caret_rect(slice, &line_offsets, vstart, vend) else { return Vec::new(); }; rects_to_vertex_bytes(&[rect], self.config.width, self.config.height) } - /// The caret rectangle for the own cursor: a thin bar at the left - /// edge of the glyph the cursor sits before (or the right edge of - /// the last glyph when the cursor is at line end). Byte→glyph - /// mapping rebases per line (QB3) and keys off the cursor's source - /// line via [`source_line_range`]. - fn caret_rect(&self, line_offsets: &[u64]) -> Option { + /// The caret rectangle for the own cursor, in slice coordinates: a + /// thin bar at the left edge of the glyph the cursor sits before (or + /// the right edge of the last glyph at line end). `None` when the + /// cursor is outside the visible slice. Byte→glyph mapping rebases + /// per line (QB3); the cursor is rebased onto the slice first (S1). + fn caret_rect( + &self, + slice: &str, + line_offsets: &[u64], + vstart: u64, + vend: u64, + ) -> Option { let own = self.own_cursor?; if self.current_buffer_id != Some(own.buffer_id) { return None; } - let cursor = own.byte.min(self.current_text.len() as u64); - let (line_lo, _) = source_line_range(&self.current_text, cursor); + let cursor = own.byte; + if cursor < vstart || cursor > vend { + return None; // scrolled off-screen + } + let slice_cursor = cursor - vstart; + let (line_lo, _) = source_line_range(slice, slice_cursor); for run in self.buffer.layout_runs() { if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo { continue; @@ -1384,7 +1568,7 @@ impl State { let line_base = line_lo; let mut x = TEXT_LEFT; for glyph in run.glyphs { - if line_base + glyph.start as u64 >= cursor { + if line_base + glyph.start as u64 >= slice_cursor { x = TEXT_LEFT + glyph.x; break; } @@ -1846,6 +2030,20 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { ) } +/// Clip a whole-file byte range `[start, end)` to the visible slice +/// `[vstart, vend)` and rebase it into slice coordinates (subtract +/// `vstart`). Returns `None` when the range is disjoint from the slice. +/// The single rebasing primitive for session S1 — caret and washes +/// route through it (Q#S4). +fn clip_rebase_range(start: u64, end: u64, vstart: u64, vend: u64) -> Option<(u64, u64)> { + let s = start.max(vstart); + let e = end.min(vend); + if e <= s { + return None; + } + Some((s - vstart, e - vstart)) +} + /// Largest char-boundary `<= index` (stable equivalent of the unstable /// `str::floor_char_boundary`). Used to snap externally-supplied byte /// offsets to valid slice points so a stale, mid-codepoint offset can't @@ -2472,6 +2670,20 @@ mod tests { assert_eq!(rendered, text, "chunks must reassemble the original text"); } + #[test] + fn clip_rebase_range_clips_to_slice_and_subtracts_vstart() { + // Visible slice is whole-file bytes [10, 20). + assert_eq!(clip_rebase_range(12, 18, 10, 20), Some((2, 8))); // inside + assert_eq!(clip_rebase_range(5, 15, 10, 20), Some((0, 5))); // clipped left + assert_eq!(clip_rebase_range(15, 25, 10, 20), Some((5, 10))); // clipped right + assert_eq!(clip_rebase_range(10, 20, 10, 20), Some((0, 10))); // exact + assert_eq!(clip_rebase_range(0, 8, 10, 20), None); // entirely before + assert_eq!(clip_rebase_range(20, 30, 10, 20), None); // entirely after + assert_eq!(clip_rebase_range(14, 14, 10, 20), None); // empty range + // vstart 0 is the unscrolled identity case. + assert_eq!(clip_rebase_range(3, 7, 0, 100), Some((3, 7))); + } + #[test] fn floor_char_boundary_snaps_into_multibyte_char() { let text = "ab→cd"; // '→' = bytes [2,5) From 6bebd770e1063fa96ec1b710a5609899cba927ee Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 30 May 2026 10:47:43 -0400 Subject: [PATCH 09/19] =?UTF-8?q?S1=20follow-up=20=E2=80=94=20scope=20the?= =?UTF-8?q?=20per-edit=20highlight=20queries=20(typing=20perf,=20Q#S6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrolling became fast after S1 but typing stayed slow: scrolling doesn't bump the CRDT generation, so the daemon's StyleGate caches and no query runs — but every keystroke bumps the generation and forced TWO whole-file tree-sitter passes on the daemon, which S1 deferred as Q#S6. With the GPU now O(visible), this was the remaining O(file) per-keystroke cost. 1. StyleSpans query scoped to the viewport. New `compute_highlight_spans_in_range` sets `QueryCursor::set_byte_range` so the capture walk is proportional to the visible range, not the whole tree; `scoped_style_spans` passes the declared viewport. The StyleGate still recomputes on the edit's generation bump (M11.7 resync), but that recompute is now O(visible). 2. FileStyleSummary (the minimap — inherently a whole-file pass) debounced to reparse-completion: skip the recompute while a reparse is in flight (`pending_edit_count() > 0`). During continuous typing the whole-file pass runs at reparse rate, not keystroke rate; when typing settles and the parse lands, it recomputes once. Together these drop the daemon's per-keystroke cost from two whole-file tree-sitter passes to one viewport-scoped pass (+ an amortized whole-file summary). Only the semantic (pmacs-gpu) path is affected; the grid/TUI path doesn't use this producer. Gates green: fmt; clippy --all-targets --workspace -D warnings (default + crdt); pmacs lib 1334; syntax 6; semantic_render 28; m11_5_semantic_acceptance 2; m4_acceptance 88. Awaiting visual confirmation: typing in a large file is now responsive. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/semantic_render.rs | 22 +++++++++++++++++++++- src/syntax.rs | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 7b065d5..66d5b8b 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -356,6 +356,18 @@ impl SemanticRenderState { buffer_id: BufferId, generation: u64, ) -> Option { + // The summary is a *whole-file* tree-sitter pass (the minimap + // needs every line). Recomputing it on every edit's generation + // bump was a per-keystroke O(file) cost — a major part of the + // typing slowness. For a grammar-backed buffer, debounce it to + // reparse-completion: skip while a reparse is in flight + // (`current_fresh` is `None`), so during continuous typing the + // whole-file pass runs at reparse rate, not keystroke rate. + if let Some(handle) = state.syntax_registry.view(buffer_id) + && handle.pending_edit_count() > 0 + { + return None; + } if self.last_summary.get(&buffer_id).copied() == Some(generation) { return None; } @@ -871,7 +883,15 @@ fn scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec Vec { + compute_highlight_spans_in_range(query, bundle, None) +} + +/// Like [`compute_highlight_spans`], but restricts the query to nodes +/// intersecting `byte_range` when `Some`. tree-sitter's +/// `QueryCursor::set_byte_range` makes the capture walk proportional to +/// the range, not the whole tree — the semantic producer passes the +/// declared viewport so styling a screenful of a huge file is +/// O(visible), not O(file) (the per-edit typing cost; framing Q#S6). +#[must_use] +pub fn compute_highlight_spans_in_range( + query: &tree_sitter::Query, + bundle: &ParseTreeBundle, + byte_range: Option>, ) -> Vec { let mut spans = Vec::new(); let mut cursor = tree_sitter::QueryCursor::new(); + if let Some(range) = byte_range { + cursor.set_byte_range(range); + } let source: &[u8] = bundle.source.as_ref(); let root = bundle.tree.root_node(); let mut iter = cursor.captures(query, root, source); From 8ed15d86a1a8b70d56f75feb3352742ab4c43564 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 10:45:23 -0400 Subject: [PATCH 10/19] =?UTF-8?q?optimistic-apply=20daemon=20support=20?= =?UTF-8?q?=E2=80=94=20Loro=20text-delta=20hot=20path=20for=20remote=20ins?= =?UTF-8?q?erts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GPU frontend's per-keystroke edits arrive as FrontendEvent::CrdtOp. The old apply path materialized the whole document and diffed it per op — O(file) per typed character on the daemon main thread. - CrdtState: persistent text-projection subscription (capture gated by an AtomicBool so per-keystroke imports don't register/drop callbacks); import_updates_with_text_deltas returns Loro's deltas; unicode_to_utf8_pos converts the insert point. - Buffer::apply_remote_crdt_op: the common single-insert delta applies straight to the rope; deletes/compound updates keep the conservative materialize+diff fallback. UTF-8 position regression test included. - SyntaxRegistry::has_pending_parse_job_for: the main-thread "parse in flight" bit render producers need for settle-gating. - TextView::pos_to_display: stack buffer for short line prefixes + valid_up_to() boundary trim — removes a per-cursor-move allocation. Co-Authored-By: Claude Fable 5 --- src/buffer.rs | 112 +++++++++++++++++++++++++++++++++++++---------- src/crdt.rs | 91 +++++++++++++++++++++++++++++++++++++- src/syntax.rs | 27 ++++++++++++ src/text_view.rs | 32 +++++++++++--- 4 files changed, 232 insertions(+), 30 deletions(-) diff --git a/src/buffer.rs b/src/buffer.rs index a145981..d45740a 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -684,16 +684,12 @@ impl Buffer { /// Used by the daemon's `FrontendEvent::CrdtOp` handler when a /// replica frontend forwards a CRDT op. The flow: /// - /// 1. Capture the rope's current bytes (rope ≡ CRDT projection - /// invariant — both have the same content pre-import). - /// 2. `crdt.import_updates(op_bytes)` — integrates the remote op - /// into the local CRDT state. CRDT convergence handles - /// concurrent edits. - /// 3. Materialize the post-import CRDT content. - /// 4. Compute the diff between pre- and post-content as a single - /// `Replace` `EditOp` (single insert/delete falls out as - /// Replace with empty inserted or empty range). - /// 5. Apply the rope stages (rope mutation + mark adjustment + + /// 1. `crdt.import_updates_with_text_deltas(op_bytes)` integrates + /// the remote op and captures Loro's text projection delta. + /// 2. Apply the common single-insert shape directly to the rope. + /// Deletes and compound updates conservatively fall back to a + /// post-import materialization + contiguous diff. + /// 3. Apply the rope stages (rope mutation + mark adjustment + /// revision bump + modified flag + `on_edit` broadcast). /// Skips the CRDT-application stage (already done in step 2) /// AND the undo push (remote ops aren't locally undoable per @@ -750,25 +746,37 @@ 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); + } + + // Conservative fallback for deletes, 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 +837,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 +1459,37 @@ impl Buffer { #[cfg(feature = "crdt")] type CrdtRoutingResult = (Option>, Option>); +/// Recognize the hot-path projection delta produced by one remote +/// insertion. Loro's retain/delete lengths use Unicode scalar offsets; +/// the caller converts the insertion point through the post-import +/// text container before applying the UTF-8 bytes to the rope. +#[cfg(feature = "crdt")] +fn single_remote_text_insert(deltas: &[Vec]) -> Option<(usize, &str)> { + let [delta] = deltas else { + return None; + }; + let mut cursor = 0usize; + let mut found = None; + for op in delta { + match op { + loro::TextDelta::Retain { retain, .. } => { + cursor = cursor.checked_add(*retain)?; + } + loro::TextDelta::Insert { insert, .. } if insert.is_empty() => {} + loro::TextDelta::Insert { insert, .. } => { + if found.is_some() { + return None; + } + found = Some((cursor, insert.as_str())); + cursor = cursor.checked_add(insert.chars().count())?; + } + loro::TextDelta::Delete { delete } if *delete == 0 => {} + loro::TextDelta::Delete { .. } => return None, + } + } + found +} + /// 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 +2923,35 @@ 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); + } + /// F25 (post-audit-round-4): a CRDT update that changes one /// codepoint into another with a shared leading UTF-8 byte /// must produce a char-boundary-aligned diff. Pre-fix, the diff --git a/src/crdt.rs b/src/crdt.rs index c7d493c..8cef9e3 100644 --- a/src/crdt.rs +++ b/src/crdt.rs @@ -40,7 +40,18 @@ //! propagation, the optional `crdt_op` field on [`crate::rope::Edit`], //! and the convergence proptest. -use loro::{ExportMode, LoroDoc, LoroEncodeError, LoroResult, UndoManager, VersionVector}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; + +use loro::{ + ContainerTrait, ExportMode, LoroDoc, LoroEncodeError, LoroResult, TextDelta, UndoManager, + VersionVector, +}; + +type TextDeltaBatches = Arc>>>; +type TextDeltaSubscription = (TextDeltaBatches, Arc, loro::Subscription); /// The CRDT-backed buffer state. /// @@ -54,6 +65,12 @@ use loro::{ExportMode, LoroDoc, LoroEncodeError, LoroResult, UndoManager, Versio /// (foreground worker materializes the initial projection). pub struct CrdtState { doc: LoroDoc, + /// Text projection deltas captured only while a remote import is + /// active. Keeping the subscription alive avoids registering and + /// dropping one callback for every typed character. + text_delta_batches: TextDeltaBatches, + text_delta_capture_enabled: Arc, + _text_delta_subscription: loro::Subscription, /// T M10.4: per-peer undo machinery. Bound to `doc`'s `peer_id` /// at construction; produces inverse ops attributed to that peer. /// @@ -90,13 +107,45 @@ impl CrdtState { // explicit get here ensures the container is registered before // any read or write. let _ = doc.get_text("body"); + let (text_delta_batches, text_delta_capture_enabled, text_delta_subscription) = + Self::subscribe_text_deltas(&doc); let undo = Self::create_undo_manager(&doc); Ok(Self { doc, + text_delta_batches, + text_delta_capture_enabled, + _text_delta_subscription: text_delta_subscription, undo: std::cell::RefCell::new(undo), }) } + fn subscribe_text_deltas(doc: &LoroDoc) -> TextDeltaSubscription { + let text = doc.get_text("body"); + let batches = Arc::new(Mutex::new(Vec::>::new())); + let capture_enabled = Arc::new(AtomicBool::new(false)); + let captured_batches = Arc::clone(&batches); + let captured_enabled = Arc::clone(&capture_enabled); + let subscription = doc.subscribe( + &text.id(), + Arc::new(move |event| { + if !captured_enabled.load(Ordering::Relaxed) { + return; + } + let mut guard = captured_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for event in event.events { + if let Some(delta) = event.diff.as_text() + && !delta.is_empty() + { + guard.push(delta.clone()); + } + } + }), + ); + (batches, capture_enabled, subscription) + } + /// T M10.4: construct a fresh `UndoManager` bound to the given doc. /// Extracted as a helper because `from_bytes` constructs it AFTER /// the initial seed insert (so the seed isn't observable as an @@ -136,9 +185,14 @@ impl CrdtState { // The buffer's starting contents (from file load, scratch // initial text, etc.) shouldn't be undoable from the user's // perspective; only post-construction edits are. + let (text_delta_batches, text_delta_capture_enabled, text_delta_subscription) = + Self::subscribe_text_deltas(&doc); let undo = Self::create_undo_manager(&doc); Ok(Self { doc, + text_delta_batches, + text_delta_capture_enabled, + _text_delta_subscription: text_delta_subscription, undo: std::cell::RefCell::new(undo), }) } @@ -302,6 +356,41 @@ impl CrdtState { self.doc.import(bytes).map(|_| ()) } + /// Import remote updates and capture Loro's text projection deltas. + /// + /// The import callback runs synchronously before `doc.import` + /// returns. Buffer's hot path uses the captured single-insert shape + /// to update its rope projection without materializing the whole + /// document. + pub fn import_updates_with_text_deltas(&self, bytes: &[u8]) -> LoroResult>> { + self.text_delta_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + self.text_delta_capture_enabled + .store(true, Ordering::Relaxed); + let import_result = self.doc.import(bytes).map(|_| ()); + self.text_delta_capture_enabled + .store(false, Ordering::Relaxed); + import_result?; + let mut guard = self + .text_delta_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Ok(std::mem::take(&mut *guard)) + } + + /// Convert a Unicode scalar offset in the current text projection + /// to its UTF-8 byte offset. + #[must_use] + pub fn unicode_to_utf8_pos(&self, pos: usize) -> Option { + self.doc.get_text("body").convert_pos( + pos, + loro::cursor::PosType::Unicode, + loro::cursor::PosType::Bytes, + ) + } + /// T M10.10 post-audit-round-4 F26 — validate that importing /// the wire bytes `bytes` would attribute every new op to /// `expected_peer_id`. diff --git a/src/syntax.rs b/src/syntax.rs index aa4b538..70106c5 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -579,6 +579,16 @@ impl SyntaxRegistry { self.parse_jobs.borrow().len() } + /// True when a dispatched parse job for `buffer` has not yet been + /// installed or drained. The syntax Lua glue records jobs here at + /// dispatch time and removes them in `_install_settled`, so this + /// is the main-thread "parse in flight" bit for render producers + /// that need to avoid stale whole-file work while typing. + #[must_use] + pub fn has_pending_parse_job_for(&self, buffer: BufferId) -> bool { + self.parse_jobs.borrow().values().any(|&bid| bid == buffer) + } + /// Lazy-compile and cache the bundled `highlights.scm` query for /// `lang_name`. Returns `None` if the language is unknown, the /// language entry has an empty query (no highlights shipped), @@ -869,4 +879,21 @@ mod tests { // Pending list cleared on make_request. assert_eq!(handle.pending_edit_count(), 0); } + + #[test] + fn registry_tracks_inflight_parse_jobs_by_buffer() { + let registry = SyntaxRegistry::new(); + let a = BufferId::next(); + let b = BufferId::next(); + + registry.record_parse_job(11, a); + registry.record_parse_job(12, b); + + assert!(registry.has_pending_parse_job_for(a)); + assert!(registry.has_pending_parse_job_for(b)); + + assert_eq!(registry.take_parse_job(11), Some(a)); + assert!(!registry.has_pending_parse_job_for(a)); + assert!(registry.has_pending_parse_job_for(b)); + } } diff --git a/src/text_view.rs b/src/text_view.rs index 9549031..b552ae4 100644 --- a/src/text_view.rs +++ b/src/text_view.rs @@ -34,6 +34,10 @@ use crate::view::{DisplayCoord, View, Viewport}; /// that is a multiple of this value. const TAB_WIDTH: u32 = 8; +/// Line-prefix lengths up to this many bytes are decoded on the stack in +/// [`TextView::pos_to_display`]; longer prefixes fall back to a heap buffer. +const STACK_CAP: usize = 256; + /// Display width of `ch` when drawn starting at column `current_col`. /// /// Tabs expand to the next [`TAB_WIDTH`]-aligned column, so they need the @@ -180,13 +184,27 @@ impl View for TextView { if take == 0 { return Some(DisplayCoord::new(row_idx as u32, 0)); } - let mut bytes = vec![0u8; take]; - buf.snapshot_rope().slice(line_start, pos, &mut bytes); - // Drop trailing bytes that don't form a complete codepoint. - while !bytes.is_empty() && std::str::from_utf8(&bytes).is_err() { - bytes.pop(); - } - let s = std::str::from_utf8(&bytes).unwrap_or(""); + // Copy [line_start, pos) into a stack buffer for the common short-line + // case, hitting the heap only for unusually long prefixes. This removes + // the per-call allocation that previously ran on every cursor move. + let mut stack_buf = [0u8; STACK_CAP]; + let mut heap_buf: Vec; + let bytes: &mut [u8] = if take <= STACK_CAP { + &mut stack_buf[..take] + } else { + heap_buf = vec![0u8; take]; + &mut heap_buf + }; + buf.snapshot_rope().slice(line_start, pos, bytes); + // If `pos` fell inside a multi-byte codepoint, keep only the bytes up to + // the last complete codepoint. `valid_up_to()` gives that boundary in + // one step, replacing the old pop-one-byte-and-revalidate loop. (Only + // trailing bytes can be invalid here, since the slice is a prefix of + // valid UTF-8 cut at `pos`.) + let s = match std::str::from_utf8(bytes) { + Ok(valid) => valid, + Err(e) => std::str::from_utf8(&bytes[..e.valid_up_to()]).unwrap(), + }; let mut col: u32 = 0; for ch in s.chars() { col += char_display_width(ch, col); From e7669c9d83fdf5f1664a042720626e1b91f0e1ae Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 10:48:16 -0400 Subject: [PATCH 11/19] producer: settle-gated styling + hold-while-stale for diagnostics/inlays/tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing-perf + render-churn fixes in the semantic producer: - Grammar styling waits for parse settle (pending edits, in-flight parse job, or no installed bundle ⇒ hold the previous spans rather than querying stale syntax per typed byte); FileStyleSummary debounces on the same condition. - CurrentLine is no longer emitted for semantic frontends — the GPU paints its own caret/current-line and the derivation forced a whole-buffer line table every frame. - Hold-while-stale: while the diag / inlay-hint / semantic-token stores are stale (document edited since the last server response), emit NOTHING instead of a clearing frame. The frontend's last-received set — which it translates through its own local edits — is strictly better than an empty wipe (diagnostics blinked out per typing burst and back in per publish, a full frontend reshape each way; inlay wipes visibly shifted line layout; the LSP-token path blanked C++ colors). A selection change during the stale window still ships, without the diagnostic kinds. - Diagnostics byte<->line table cached per buffer revision (was an O(buffer) rope copy + scan on every tick a diagnostic was visible). - Empty->empty Decorations frames on generation bumps suppressed. Co-Authored-By: Claude Fable 5 --- src/semantic_render.rs | 642 ++++++++++++++++++++++++++++++----------- 1 file changed, 475 insertions(+), 167 deletions(-) diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 66d5b8b..22ef343 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -124,6 +124,21 @@ pub struct SemanticRenderState { /// Only the grammar (tree-sitter) path is gated; the LSP-token path /// has no comparably cheap handle and recomputes as before. last_style_gate: HashMap, + /// Cached byte↔line table for the diagnostics projection, keyed + /// by buffer revision. Building it costs an O(buffer) rope copy + /// plus a full scan; before this cache, that ran on *every tick* + /// while diagnostics were on screen (the table is only consulted + /// when the store is non-stale and non-empty) — a steady-state + /// CPU burn for a value that changes only when the buffer does. + diag_line_cache: HashMap, +} + +/// One [`SemanticRenderState::diag_line_cache`] entry: the line-start +/// offsets and source length of a buffer at `revision`. +struct DiagLineCache { + revision: u64, + line_starts: Vec, + source_len: u64, } /// Recompute gate for [`scoped_style_spans`] on a grammar-backed @@ -169,6 +184,7 @@ impl SemanticRenderState { last_adornments: HashMap::new(), last_summary: HashMap::new(), last_style_gate: HashMap::new(), + diag_line_cache: HashMap::new(), } } @@ -220,14 +236,29 @@ impl SemanticRenderState { // already-sent baseline — can skip the whole block. The LSP- // token path returns `None` (no cheap revision) and recomputes // every tick as before. - let style_gate = grammar_style_key(state, &vp, generation); + let style_parse_not_ready = grammar_style_parse_not_ready(state, vp.buffer_id); + // The LSP-token styling authority (grammar-less buffers, e.g. + // C++) gets the same hold: while the semantic-token store is + // stale (document edited since the last token response), + // `lsp_scoped_style_spans` would compute an empty set, and + // shipping that clears the frontend's colors for the whole + // stale window — the styling twin of the diagnostics blink. + let style_tokens_stale = lsp_style_tokens_stale(state, vp.buffer_id); + let style_hold = style_parse_not_ready || style_tokens_stale; + let style_gate = (!style_hold).then(|| grammar_style_key(state, &vp, generation)); + let style_gate = style_gate.flatten(); let style_unchanged = match (&style_gate, self.last_style_gate.get(&vp.buffer_id)) { (Some(g), Some(prev)) => g.matches(prev) && self.last_sent.contains_key(&vp.buffer_id), _ => false, }; - if style_unchanged { - // Styling cannot have changed since the last computation; - // emit nothing and skip the query. + if style_hold || style_unchanged { + // If the style key is unchanged, styling cannot have + // changed since the last computation. If a grammar parse is + // still pending (or the LSP token store is stale), keep the + // previous spans briefly rather than querying and reshaping + // stale syntax on every typed byte; the parse-bundle + // revision (or the next token response) will force a fresh + // frame as soon as it settles. } else { match style_gate { Some(g) => { @@ -243,8 +274,36 @@ impl SemanticRenderState { // --- Decorations (T M11.3 producer, T M11.4 diff) --- let decorations = self.scoped_decorations(state, &vp); let prev = self.last_decorations.get(&vp.buffer_id); + // Hold-while-stale: while the diag store is stale (document + // edited since the last `publishDiagnostics`), this frame has + // no authoritative diagnostic positions. The frontend's + // last-received set — which it translates through its own + // local edits — is strictly better than anything we can ship: + // an empty frame wipes it (diagnostics blink out on the first + // keystroke of every burst and back in after the next publish, + // one full frontend reshape each way), and re-shipping the + // store's items would anchor pre-edit positions over post-edit + // text (the M11.8 artifact). So as long as the + // *non-diagnostic* part is unchanged, say nothing and leave + // the baseline untouched — staleness clears on the next + // publishDiagnostics absorption, and the generation transition + // since the held baseline forces that frame full. + let held = diagnostics_store_stale(state, vp.buffer_id) + && prev.is_some_and(|p| { + decorations + .iter() + .eq(p.items.iter().filter(|d| !is_diagnostic_kind(d.kind))) + }); let full = prev.is_none_or(|p| p.visible != vp.visible || p.generation != generation); - if full { + if held { + // No new information for the frontend this frame. (A + // selection change during the stale window falls through + // to the branches below and ships without diagnostics — + // rare, and better than pinning a dead selection.) + } else if full { + let suppress_empty_generation_bump = prev.is_some_and(|p| { + p.visible == vp.visible && p.items.is_empty() && decorations.is_empty() + }); self.last_decorations.insert( vp.buffer_id, LastFrame { @@ -253,15 +312,17 @@ impl SemanticRenderState { generation, }, ); - out.push(InstanceMessage::Decorations { - buffer_id: vp.buffer_id, - generation, - full: true, - segments: vec![DecorationSegment { - range: vp.visible, - decorations, - }], - }); + if !suppress_empty_generation_bump { + out.push(InstanceMessage::Decorations { + buffer_id: vp.buffer_id, + generation, + full: true, + segments: vec![DecorationSegment { + range: vp.visible, + decorations, + }], + }); + } } else { let prev = prev.expect("checked is_none_or above"); let intervals = changed_intervals(&prev.items, &decorations, |d| d.range); @@ -308,6 +369,17 @@ impl SemanticRenderState { state: &EditorState, vp: &DeclaredViewport, ) -> Option { + // Hold-while-stale — mirrors the Decorations hold in + // `render_frame`. An empty frame here wipes the frontend's + // cached virtual text mid-typing-burst, and inline adornments + // occupy layout space: the wipe visibly shifts real glyphs + // (and forces a reshape), then the post-refresh re-emit + // shifts them back. The frontend's locally-translated cache + // is the better picture until a fresh `inlayHint` response + // clears the stale flag and re-emits through the diff below. + if inlay_store_stale(state, vp.buffer_id) { + return None; + } let adornments = scoped_inline_adornments(state, vp); let should_emit = match self.last_adornments.get(&vp.buffer_id) { // First sight of this buffer: speak only if there is @@ -359,13 +431,13 @@ impl SemanticRenderState { // 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 a grammar-backed buffer, debounce it to - // reparse-completion: skip while a reparse is in flight - // (`current_fresh` is `None`), so during continuous typing the - // whole-file pass runs at reparse rate, not keystroke rate. - if let Some(handle) = state.syntax_registry.view(buffer_id) - && handle.pending_edit_count() > 0 - { + // 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) { @@ -456,56 +528,31 @@ impl SemanticRenderState { } } - fn scoped_decorations(&self, state: &EditorState, vp: &DeclaredViewport) -> Vec { + fn scoped_decorations( + &mut self, + state: &EditorState, + vp: &DeclaredViewport, + ) -> Vec { let core = state.core.borrow(); let registry = core.registry.clone(); let reg = registry.borrow(); let mut out = Vec::new(); - // Byte<->line mapping is needed by both the CurrentLine - // derivation and the diagnostics projection, and - // `buffer_source_bytes` is an O(n) rope copy. This runs every - // tick in the daemon's hot loop, so materialize at most once - // per call and reuse — never twice (the pre-9.2 shape copied - // separately in each branch). - let mut line_info: Option<(Vec, Vec)> = None; - - // Selection + CurrentLine — per-window (per-frontend) state. - // Only this session's active window for the declared buffer - // contributes either kind. - // - // Q#3 (per-line CurrentLine cadence, stance β) falls out of the - // existing M11.4 diff: `render_frame` compares the new - // decoration Vec against the last sent one and emits only on - // change. Horizontal cursor motion within a single line - // produces an identical `CurrentLine` range and an identical - // overall Vec, so `changed_intervals` returns empty and nothing - // ships. No `last_cursor_line` cache is needed at this layer. + // Selection is per-window (per-frontend) state. CurrentLine is + // deliberately not emitted for semantic frontends: the GPU has + // CursorByte and paints its own caret/current-line affordances. + // Emitting CurrentLine here forced a whole-buffer line table on + // every frame even though pmacs-gpu ignores its own current-line + // wash. if let Some(win) = core.active_window_for(self.frontend_id) && win.buffer_id == vp.buffer_id + && let Some((lo, hi)) = win.region() + && let Some(range) = clip_to_viewport(lo, hi, vp) { - if let Some((lo, hi)) = win.region() - && let Some(range) = clip_to_viewport(lo, hi, vp) - { - out.push(Decoration { - range, - kind: DecorationKind::Selection, - }); - } - if let Ok(buf) = reg.get(vp.buffer_id) { - let (source, line_starts) = line_info.get_or_insert_with(|| { - let s = buffer_source_bytes(buf); - let ls = line_start_offsets(&s); - (s, ls) - }); - let (lo, hi) = current_line_range(line_starts, source.len() as u64, win.cursor); - if let Some(range) = clip_to_viewport(lo, hi, vp) { - out.push(Decoration { - range, - kind: DecorationKind::CurrentLine, - }); - } - } + out.push(Decoration { + range, + kind: DecorationKind::Selection, + }); } // Diagnostics — keyed in the shared store by the file URI the @@ -530,12 +577,30 @@ impl SemanticRenderState { && !diags.is_empty() && let Ok(buf) = reg.get(vp.buffer_id) { - let (source, line_starts) = line_info.get_or_insert_with(|| { - let s = buffer_source_bytes(buf); - let ls = line_start_offsets(&s); - (s, ls) - }); - let source_len = source.len() as u64; + // Byte<->line mapping, cached per buffer revision — + // rebuilding it is an O(buffer) rope copy + scan, far + // too expensive to repeat on every tick a diagnostic + // is on screen. + let cache = self + .diag_line_cache + .entry(vp.buffer_id) + .and_modify(|c| { + if c.revision != buf.revision() { + let s = buffer_source_bytes(buf); + c.revision = buf.revision(); + c.line_starts = line_start_offsets(&s); + c.source_len = s.len() as u64; + } + }) + .or_insert_with(|| { + let s = buffer_source_bytes(buf); + DiagLineCache { + revision: buf.revision(), + line_starts: line_start_offsets(&s), + source_len: s.len() as u64, + } + }); + let (line_starts, source_len) = (&cache.line_starts, cache.source_len); for d in &diags { let lo = line_col_to_byte(line_starts, source_len, d.start_line, d.start_col); let hi = line_col_to_byte(line_starts, source_len, d.end_line, d.end_col); @@ -723,6 +788,61 @@ fn clip_decorations(iv: ByteRange, decos: &[Decoration]) -> Vec { .collect() } +/// True for the four diagnostic-underline decoration kinds — the +/// family whose emission is gated on diag-store staleness by the +/// hold-while-stale logic in `render_frame`. +fn is_diagnostic_kind(kind: DecorationKind) -> bool { + matches!( + kind, + DecorationKind::DiagnosticError + | DecorationKind::DiagnosticWarning + | DecorationKind::DiagnosticInfo + | DecorationKind::DiagnosticHint + ) +} + +/// True when `buffer_id`'s entry in the diagnostics store is stale +/// (the document changed since the last `publishDiagnostics` +/// absorption). Buffers with no file URI are never stale. +fn diagnostics_store_stale(state: &EditorState, buffer_id: BufferId) -> bool { + let core = state.core.borrow(); + let Some(uri) = buffer_file_uri(&core, buffer_id) else { + return false; + }; + let store = state.lsp_manager.borrow().diag_store(); + let guard = store.lock().expect("diag store mutex poisoned"); + guard.is_stale(&uri) +} + +/// Style-family staleness for the LSP-token authority. True only for +/// a buffer with **no** tree-sitter view (policy A routes those +/// through `lsp_scoped_style_spans`) whose semantic-token store entry +/// is stale. Grammar-backed buffers always return `false` — their +/// styling freshness is `grammar_style_parse_not_ready`'s job. +fn lsp_style_tokens_stale(state: &EditorState, buffer_id: BufferId) -> bool { + if state.syntax_registry.view(buffer_id).is_some() { + return false; + } + let core = state.core.borrow(); + let Some(uri) = buffer_file_uri(&core, buffer_id) else { + return false; + }; + let store = state.lsp_manager.borrow().semantic_token_store(); + let guard = store.lock().expect("semantic token store mutex poisoned"); + guard.is_stale(&uri) +} + +/// Inlay-hint twin of [`diagnostics_store_stale`]. +fn inlay_store_stale(state: &EditorState, buffer_id: BufferId) -> bool { + let core = state.core.borrow(); + let Some(uri) = buffer_file_uri(&core, buffer_id) else { + return false; + }; + let store = state.lsp_manager.borrow().inlay_hint_store(); + let guard = store.lock().expect("inlay-hint store mutex poisoned"); + guard.is_stale(&uri) +} + /// Map an LSP diagnostic severity onto the wire decoration kind. fn severity_to_kind(sev: crate::diag::DiagnosticSeverity) -> DecorationKind { use crate::diag::DiagnosticSeverity as S; @@ -770,31 +890,6 @@ fn buffer_source_bytes(buf: &crate::buffer::Buffer) -> Vec { bytes } -/// Byte range `(start, end)` of the line containing `cursor`, where -/// `start` is the position right after the previous `\n` (or 0 for the -/// first line) and `end` is the position of the next `\n` (or -/// `source_len` for the last line). Used by `scoped_decorations` to -/// emit `DecorationKind::CurrentLine`; clamps so a cursor at or past -/// `source_len` returns the last line's range rather than indexing -/// out. -fn current_line_range(line_starts: &[u64], source_len: u64, cursor: u64) -> (u64, u64) { - // `partition_point` returns the count of leading elements satisfying - // the predicate, i.e. the index of the first `line_start > cursor`. - // Subtracting 1 yields the index of the largest `line_start <= - // cursor`. `line_starts` always starts with 0, so the saturating - // sub is defensive against an empty `line_starts`. - let idx = line_starts - .partition_point(|&start| start <= cursor) - .saturating_sub(1); - let lo = line_starts.get(idx).copied().unwrap_or(0); - let hi = line_starts - .get(idx + 1) - .copied() - .unwrap_or(source_len) - .min(source_len); - (lo, hi) -} - /// Byte offset of the start of each line (index 0 = byte 0; one entry /// per line, where a line is a maximal run ended by `\n`). fn line_start_offsets(source: &[u8]) -> Vec { @@ -842,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 @@ -1292,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); @@ -1324,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 = { @@ -1358,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); { @@ -1401,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] @@ -1428,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) @@ -1511,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 @@ -1648,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(), @@ -1671,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"); @@ -1912,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(); @@ -1953,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(); @@ -2159,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); @@ -2175,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); @@ -2267,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")]); From d380358f2c1bb82a5d6672645370f22e12dba397 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 10:49:34 -0400 Subject: [PATCH 12/19] =?UTF-8?q?pmacs-gpu:=20optimistic=20typing=20?= =?UTF-8?q?=E2=80=94=20local=20CRDT=20inserts,=20writer=20thread,=20frame?= =?UTF-8?q?=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing no longer round-trips. Plain chars, Enter, and Tab (whose default bindings reduce to a plain insert_char) apply to the local Loro replica immediately and ship as FrontendEvent::CrdtOp: - optimistic_crdt_insert: gated on DispatchIdle + a fresh CursorByte + no own-window selection (CUA type-over must round-trip into the region-aware commands, which a raw op bypasses). Predicted-cursor floor ignores stale in-flight CursorBytes; round-trip keys typed behind unconfirmed inserts defer until the floor confirms. Optimistic Enter scroll-follows immediately and re-declares the scoped viewport. - attach: a writer thread owns the socket write half, so the winit thread never blocks on daemon backpressure; send_crdt_op added. - Incremental text maintenance: Loro text deltas patch current_text and per-line byte/char offset tables in place (no whole-rope materialization per keystroke); cached spans/decorations/adornments translate through each edit. - Unconfirmed-edit journal: incoming StyleSpans/Decorations frames carry the daemon's CRDT version scalar as generation; the GPU computes the same per-peer counter sum locally, prunes confirmed entries, and translates the frame's ranges through the rest — a frame computed before an in-flight keystroke no longer repaints the viewport's colors a few bytes left for one frame. - Extend-at-end heuristic: a span ending exactly at a pure insert point extends over the typed text, so new chars inherit the preceding token's color instead of blinking default until the next parse settles. - Per-edit viewport re-declaration narrowed to origin moves; PMACS_GPU_DEBUG_APPLY times per-message apply cost. Co-Authored-By: Claude Fable 5 --- pmacs-gpu/src/attach.rs | 120 ++-- pmacs-gpu/src/main.rs | 1253 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 1293 insertions(+), 80 deletions(-) diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index e683d13..0e4cf84 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -18,13 +18,14 @@ use std::os::unix::net::UnixStream; use std::path::Path; -use std::sync::{Arc, Mutex}; +use std::sync::mpsc; use std::thread; use pmacs_protocol::{ - AttachRequest, BufferId, ByteRange, FrontendCapabilities, FrontendEvent, FrontendId, Hello, - InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, - TransportError, is_supported_protocol_version, read_message, write_message, + AttachRequest, BufferId, ByteRange, CrdtOp, FrontendCapabilities, FrontendEvent, FrontendId, + Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, TransportError, is_supported_protocol_version, read_message, + write_message, }; use winit::event_loop::EventLoopProxy; @@ -76,10 +77,10 @@ pub enum AttachEvent { /// Connect, handshake, and spawn the reader thread. /// /// Returns once the handshake has completed and the reader thread is -/// running. The reader thread owns the read half of the stream; the -/// returned [`AttachClient`] retains the write half so the main loop -/// can eventually emit `FrontendEvent`s back to the daemon (session 4 -/// will need this — selection / viewport / edits travel that way). +/// running. The reader thread owns the read half of the stream; a +/// writer thread owns the write half. The returned [`AttachClient`] +/// queues outbound `FrontendEvent`s so the winit UI thread never blocks +/// on daemon socket backpressure. /// /// **Initial window size note** — `AttachRequest::initial_size` is /// nominally a `CellSize` (rows × cols) anchored to the TUI. The @@ -146,12 +147,13 @@ pub fn connect( }; write_message(&mut handshake_stream, &req).map_err(AttachClientError::Handshake)?; - // Split read/write halves for the reader thread + main-thread - // write path. UnixStream clones share the underlying FD with - // independent buffer state — safe to read on one clone while the - // other writes (the FD is full-duplex). + // Split read/write halves for the reader thread + writer thread. + // UnixStream clones share the underlying FD with independent + // buffer state — safe to read on one clone while the other writes + // (the FD is full-duplex). let mut read_stream = stream.try_clone().map_err(AttachClientError::Connect)?; let write_stream = stream; + let (writer_tx, writer_rx) = mpsc::channel::(); // Reader thread. Each iteration: block on read_message, decode, // forward via the event-loop proxy. Exits cleanly on EOF / any @@ -181,22 +183,32 @@ pub fn connect( }) .expect("spawn attach reader thread"); + // Writer thread. Socket writes can block when the daemon falls + // behind; doing them here keeps keyboard input, redraws, and + // message application off that backpressure path. + thread::Builder::new() + .name("pmacs-gpu attach writer".into()) + .spawn(move || { + let mut write_stream = write_stream; + while let Ok(event) = writer_rx.recv() { + if let Err(e) = write_message(&mut write_stream, &event) { + eprintln!("pmacs-gpu: attach writer stopped: {e}"); + return; + } + } + }) + .expect("spawn attach writer thread"); + Ok(AttachClient { - write_stream: Arc::new(Mutex::new(write_stream)), + writer_tx, frontend_id: hello.assigned_frontend_id, }) } -/// Handle the main loop keeps after `connect` returns. Session 4 -/// wires the write side for `FrontendEvent::Viewport` emission; -/// future sessions will add cursor / edit / focus / detach. -/// -/// The write half is wrapped in `Arc>` because, while -/// pmacs-gpu's event loop is single-threaded, a future multi-window -/// shape might emit events from several places concurrently. The -/// lock cost is one mutex per emitted frame — negligible. +/// Handle the main loop keeps after `connect` returns. It queues +/// `FrontendEvent`s for the attach writer thread. pub struct AttachClient { - write_stream: Arc>, + writer_tx: mpsc::Sender, /// Assigned by the daemon in the `Hello` response. Every /// `FrontendEvent` carries this so the daemon can route input back /// to the per-session `SemanticRenderState`. @@ -204,6 +216,11 @@ pub struct AttachClient { } impl AttachClient { + /// Frontend id assigned by the daemon in the initial `Hello`. + pub fn frontend_id(&self) -> FrontendId { + self.frontend_id + } + /// Send a `FrontendEvent::Viewport` to the daemon. The daemon's /// `SemanticRenderState::set_viewport` feeds the spans producer; /// without this call the daemon ships no `StyleSpans` for the @@ -214,19 +231,12 @@ impl AttachClient { visible: ByteRange, generation: u64, ) -> Result<(), TransportError> { - let mut stream = self - .write_stream - .lock() - .expect("attach write-stream mutex poisoned"); - write_message( - &mut *stream, - &FrontendEvent::Viewport { - frontend_id: self.frontend_id, - buffer_id, - visible, - generation, - }, - ) + self.send_event(FrontendEvent::Viewport { + frontend_id: self.frontend_id, + buffer_id, + visible, + generation, + }) } /// Send a `FrontendEvent::Key` to the daemon (session B1). The @@ -237,18 +247,32 @@ impl AttachClient { /// 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> { - let mut stream = self - .write_stream - .lock() - .expect("attach write-stream mutex poisoned"); - write_message( - &mut *stream, - &FrontendEvent::Key(KeyEvent { - frontend_id: self.frontend_id, - key, - mods, - timestamp_ns: 0, - }), - ) + self.send_event(FrontendEvent::Key(KeyEvent { + frontend_id: self.frontend_id, + key, + mods, + timestamp_ns: 0, + })) + } + + /// Send a locally-authored CRDT operation to the daemon. The GPU + /// uses this for idle plain-text insertion after applying the same + /// op to its local Loro replica, avoiding a Key round trip on the + /// hot typing path. + pub fn send_crdt_op(&self, buffer_id: BufferId, op: CrdtOp) -> Result<(), TransportError> { + self.send_event(FrontendEvent::CrdtOp { + frontend_id: self.frontend_id, + buffer_id, + op, + }) + } + + fn send_event(&self, event: FrontendEvent) -> Result<(), TransportError> { + self.writer_tx.send(event).map_err(|_| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "attach writer thread stopped", + )) + }) } } diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 344c24e..1da86fa 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -26,14 +26,15 @@ mod attach; use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use glyphon::{ Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, }; +use loro::{ContainerTrait, ExportMode}; use pmacs_protocol::{ - AdornmentContent, AdornmentPlacement, BufferId, ByteRange, Decoration, DecorationKind, + AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CrdtOp, Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, Key as ProtocolKey, Modifiers, SelectionSnapshot, StyleSegment, StyleSpan, cell::{Color as CellColor, Style as CellStyle}, @@ -221,6 +222,8 @@ struct App { modifiers: winit::keyboard::ModifiersState, } +type LoroTextDeltaBatches = Arc>>>; + /// All resources owned by one running pmacs-gpu instance. struct State { window: Arc, @@ -238,14 +241,35 @@ struct State { /// What the buffer is currently shaped to. Held so we can detect /// no-op updates and skip the re-shape. current_text: String, - /// Code-shape data derived from `current_text`, used to give the - /// minimap horizontal structure even though `FileStyleSummary` - /// carries only one dominant style per line. + /// Buffer-absolute byte offset for each source line in + /// `current_text`. Updated with text changes and reused by + /// reshape/scroll logic so those paths do not rescan the whole + /// file on every semantic frame. + current_line_starts: Vec, + /// Buffer-absolute Unicode scalar offset for each source line in + /// `current_text`. Loro's text event deltas use Unicode offsets + /// on native builds, so this lets the CRDT hot path convert a + /// retain/delete position to bytes by scanning only one source + /// line instead of the whole prefix. + current_line_char_starts: Vec, + /// Code-shape data used to give the minimap horizontal structure + /// even though `FileStyleSummary` carries only one dominant style + /// per line. Refreshed when a new summary lands, keeping this + /// cache in cadence with the debounced minimap data rather than + /// rebuilding it for every typed byte. current_line_shapes: Vec, /// Local CRDT replica seeded by `BufferSnapshot`. `None` in /// hello-world mode or before the first snapshot arrives in /// attach mode. loro_doc: Option, + /// Pending text diff batches captured from the local Loro replica. + /// `CrdtOp` imports fire the subscription synchronously; the GPU + /// drains these deltas and patches `current_text` incrementally + /// instead of materializing the whole Loro text after each edit. + loro_text_delta_batches: Option, + /// Kept alive for as long as `loro_doc` is active. Dropping it + /// unsubscribes before the next buffer snapshot replaces the doc. + loro_text_subscription: Option, /// Buffer the current rope text + spans interpret. Set when a /// `BufferSnapshot` arrives; used as the routing key for /// `StyleSpans` updates (drop those for other buffers). @@ -317,6 +341,49 @@ struct State { /// shifts visible bytes, buffer switch) so the producer scopes /// `StyleSpans` to what's on screen without per-frame churn (Q#S5). last_viewport_sent: Option<(u64, u64)>, + /// Frontend id assigned by the daemon. Needed for locally-authored + /// optimistic CRDT ops, whose Loro peer id must match the + /// authenticated frontend id the daemon sees on the socket. + local_frontend_id: Option, + /// Daemon-side key dispatcher state. Plain printable chars are + /// optimistically applied only while this is true; when false, + /// keys round-trip so minibuffer and prefix commands keep their + /// daemon-owned semantics. + dispatch_idle: bool, + /// Whether `own_cursor` is still an authoritative position for + /// local optimistic insertion. Round-tripped keys can move the + /// daemon cursor in ways the GPU does not predict, so they mark + /// this false until the next `CursorByte`. + cursor_fresh: bool, + /// Furthest locally-predicted cursor after optimistic inserts that + /// the daemon has not yet confirmed. `CursorByte` frames already + /// in flight can arrive after local typing; accepting one below + /// this floor would rewind subsequent optimistic inserts and + /// scramble their order. + optimistic_cursor_floor: Option, + /// Round-trip keys typed while optimistic inserts are still + /// awaiting confirmation. Sending a backward-moving key before + /// the floor is acknowledged would make its legitimate cursor + /// result indistinguishable from an older in-flight frame. + deferred_round_trip_keys: Vec<(ProtocolKey, Modifiers)>, + /// Optimistic local edits not yet known to be reflected in + /// incoming producer frames. Each entry pairs the version scalar + /// of this replica's doc *after* the edit applied (computed by + /// [`loro_version_scalar`], the same per-peer counter sum the + /// daemon stamps into `StyleSpans` / `Decorations` `generation`) + /// with the projection edit itself. On frame arrival, entries at + /// or below the frame's generation are pruned and the frame's + /// byte ranges are translated through the remainder — otherwise a + /// frame computed before an in-flight keystroke repaints the + /// viewport's colors a few bytes left of the text (the typing + /// "color shimmer"). Cleared whenever the cache is rebuilt + /// wholesale (snapshot / full-materialization fallback). + /// + /// Caveat (accepted): scalars from *divergent* replicas are not + /// causally comparable, so a peer edit racing our unconfirmed + /// ops can mis-prune by one frame; the next generation-keyed + /// full resync self-corrects. + unconfirmed_edits: Vec<(u64, TextProjectionEdit)>, } /// pmacs-gpu's own cursor position, mirrored from `CursorByte`. @@ -367,6 +434,9 @@ impl ApplicationHandler for App { let proxy = self.proxy.take().expect("proxy taken twice"); match attach::connect(&socket, proxy) { Ok(client) => { + if let Some(state) = self.state.as_mut() { + state.set_frontend_id(client.frontend_id()); + } self.attach_client = Some(client); } Err(e) => { @@ -401,6 +471,44 @@ impl ApplicationHandler for App { && should_forward_key(pkey, pmods) && let Some(client) = self.attach_client.as_ref() { + if let Some(op) = self + .state + .as_mut() + .and_then(|state| state.optimistic_crdt_insert(pkey, pmods)) + { + if debug_input() { + eprintln!( + "pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B", + op.buffer_id, + op.op.bytes.len() + ); + } + if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) { + eprintln!("pmacs-gpu: send_crdt_op failed: {e}"); + } + // An optimistic Enter near the bottom edge can + // scroll; re-declare the scoped viewport so + // the producer styles the newly visible lines. + if let Some(vp) = op.viewport + && let Err(e) = + client.send_viewport(vp.buffer_id, vp.visible, vp.generation) + { + eprintln!("pmacs-gpu: send Viewport failed: {e}"); + } + return; + } + if let Some(state) = self.state.as_mut() { + if state.defer_round_trip_key_if_needed(pkey, pmods) { + if debug_input() { + eprintln!( + "pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \ + pending optimistic cursor" + ); + } + return; + } + state.mark_cursor_stale_after_round_trip(); + } if debug_input() { eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}"); } @@ -436,7 +544,16 @@ impl ApplicationHandler for App { }; match event { AppEvent::Attach(AttachEvent::Message(msg)) => { + let debug_apply = debug_apply(); + let apply_start = debug_apply.then(std::time::Instant::now); + let label = debug_apply.then(|| instance_message_label(msg.as_ref())); let follow_up = state.apply_attach_message(*msg); + if let (Some(start), Some(label)) = (apply_start, label) { + eprintln!( + "pmacs-gpu apply: {label}={}us", + std::time::Instant::now().duration_since(start).as_micros() + ); + } // If the message triggered a follow-up Viewport // (currently: every BufferSnapshot does), emit it back // to the daemon. The daemon's `SemanticRenderState` @@ -451,6 +568,17 @@ impl ApplicationHandler for App { { eprintln!("pmacs-gpu: send Viewport failed: {e}"); } + let ready_keys = state.take_ready_round_trip_keys(); + if let Some(client) = self.attach_client.as_ref() { + for (key, mods) in ready_keys { + if debug_input() { + eprintln!("pmacs-gpu flush_key: {key:?} mods={mods:?}"); + } + if let Err(e) = client.send_key(key, mods) { + eprintln!("pmacs-gpu: flush send_key failed: {e}"); + } + } + } } AppEvent::Attach(AttachEvent::Disconnected(reason)) => { eprintln!("pmacs-gpu: daemon disconnected ({reason})"); @@ -470,6 +598,40 @@ struct ViewportSend { generation: u64, } +#[derive(Debug)] +struct CrdtOpSend { + buffer_id: BufferId, + op: CrdtOp, + /// A scoped-viewport re-declaration when the optimistic insert + /// scrolled the view (Enter on the bottom visible line). Sent + /// after the op so the producer styles the newly visible range. + viewport: Option, +} + +/// The literal text `key` inserts when handled optimistically, or +/// `None` for keys that must round-trip through the daemon. +/// +/// `Enter` and `Tab` qualify alongside printable chars because their +/// default bindings (`buffer.newline` / `buffer.tab`) reduce to plain +/// `insert_char(10)` / `insert_char(9)` — byte-identical to a +/// self-insert, so the local application cannot diverge from what the +/// daemon will do with the same op. Two caveats are the caller's job: +/// `optimistic_crdt_insert` round-trips when an own-window selection +/// is active (the daemon commands consume the region first — CUA +/// type-over — which a raw op can't), and modified variants (`S-RET`, +/// `C-TAB`, …) return `None` here: a keymap may bind them to anything. +fn optimistic_insert_text(key: ProtocolKey, mods: Modifiers, chbuf: &mut [u8; 4]) -> Option<&str> { + if !is_plain_text_modifiers(mods) { + return None; + } + match key { + ProtocolKey::Char(ch) if !ch.is_control() => Some(ch.encode_utf8(chbuf)), + ProtocolKey::Enter if mods.is_empty() => Some("\n"), + ProtocolKey::Tab if mods.is_empty() => Some("\t"), + _ => None, + } +} + impl QuadRenderer { fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -614,6 +776,7 @@ impl State { None, ); buffer.shape_until_scroll(&mut font_system, false); + let (current_line_starts, current_line_char_starts) = line_offset_tables(initial_text); Self { window, @@ -629,8 +792,12 @@ impl State { quad_renderer, buffer, current_text: initial_text.to_owned(), + current_line_starts, + current_line_char_starts, current_line_shapes: minimap_line_shapes(initial_text), loro_doc: None, + loro_text_delta_batches: None, + loro_text_subscription: None, current_buffer_id: None, current_spans: Vec::new(), current_decorations: Vec::new(), @@ -641,9 +808,186 @@ impl State { scroll_top: 0, view_range: (0, 0), last_viewport_sent: None, + local_frontend_id: None, + dispatch_idle: false, + cursor_fresh: false, + optimistic_cursor_floor: None, + deferred_round_trip_keys: Vec::new(), + unconfirmed_edits: Vec::new(), } } + fn set_frontend_id(&mut self, frontend_id: FrontendId) { + self.local_frontend_id = Some(frontend_id); + if let Some(doc) = self.loro_doc.as_ref() + && let Err(e) = doc.set_peer_id(frontend_id.0) + { + eprintln!("pmacs-gpu: failed to set local Loro peer id: {e:?}"); + } + } + + fn optimistic_crdt_insert(&mut self, key: ProtocolKey, mods: Modifiers) -> Option { + if !self.dispatch_idle || !self.cursor_fresh { + return None; + } + // CUA type-over: with an active selection, typing replaces the + // region. Those semantics live in the daemon's region-aware + // insert commands (`buffer.self-insert` / `newline` / `tab`), + // which a raw CrdtOp insert would bypass — so an own-window + // selection sends the key round-trip instead. Our own + // selection arrives as a `Selection` decoration (peer + // selections live in `peer_presences` and don't gate). The + // daemon's replace clears the region, the next Decorations + // frame clears the wash, and typing resumes optimistically. + if self + .current_decorations + .iter() + .any(|d| d.kind == DecorationKind::Selection) + { + return None; + } + let mut chbuf = [0u8; 4]; + let insert = optimistic_insert_text(key, mods, &mut chbuf)?; + let frontend_id = self.local_frontend_id?; + let own = self.own_cursor?; + if self.current_buffer_id != Some(own.buffer_id) { + return None; + } + let cursor = usize::try_from(own.byte).ok()?; + if cursor > self.current_text.len() || !self.current_text.is_char_boundary(cursor) { + return None; + } + let doc = self.loro_doc.as_ref()?; + let peer_id = frontend_id.0; + if doc.peer_id() != peer_id + && let Err(e) = doc.set_peer_id(peer_id) + { + eprintln!("pmacs-gpu: failed to set optimistic Loro peer id: {e:?}"); + return None; + } + + let delta_batches = self.loro_text_delta_batches.clone()?; + clear_loro_text_delta_batches(&delta_batches); + let before = doc.oplog_vv(); + if let Err(e) = doc.get_text(LORO_TEXT_CONTAINER).insert_utf8(cursor, insert) { + eprintln!("pmacs-gpu: optimistic insert failed: {e:?}"); + return None; + } + let bytes = doc + .export(ExportMode::updates(&before)) + .expect("export local optimistic Loro update"); + let drained = drain_loro_text_delta_batches(&delta_batches); + if drained.is_empty() { + let text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); + self.set_text(&text); + // Cache rebuilt wholesale — there are no translated + // anchors left for frame translation to protect. + self.unconfirmed_edits.clear(); + } else { + match self.apply_loro_text_delta_batches(&drained) { + Ok(edits) => { + // Journal this keystroke so producer frames the + // daemon computed before integrating it can be + // translated on arrival (see `unconfirmed_edits`). + // The scalar is read *after* the local insert, so + // any frame stamped at or beyond it includes us. + let scalar = self.loro_doc.as_ref().map_or(0, loro_version_scalar); + self.unconfirmed_edits + .extend(edits.into_iter().map(|e| (scalar, e))); + } + Err(reason) => { + eprintln!( + "pmacs-gpu: optimistic text update failed ({reason}); falling back to \ + full materialization" + ); + let text = self + .loro_doc + .as_ref() + .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); + if let Some(text) = text { + self.set_text(&text); + } + self.unconfirmed_edits.clear(); + } + } + } + let predicted = OwnCursor { + buffer_id: own.buffer_id, + byte: own.byte.saturating_add(insert.len() as u64), + }; + self.own_cursor = Some(predicted); + self.optimistic_cursor_floor = Some(predicted); + // Follow the caret NOW rather than when the daemon's + // `CursorByte` confirms — an optimistic Enter on the bottom + // visible line moves the caret to a line below the slice, and + // waiting a round trip to scroll reads as a hitch. + let viewport = if self.scroll_to_cursor() { + self.reshape(); + self.viewport_send_if_changed(own.buffer_id) + } else { + None + }; + Some(CrdtOpSend { + buffer_id: own.buffer_id, + op: CrdtOp { peer_id, bytes }, + viewport, + }) + } + + fn mark_cursor_stale_after_round_trip(&mut self) { + self.cursor_fresh = false; + } + + fn apply_loro_text_delta_batches( + &mut self, + delta_batches: &[Vec], + ) -> Result, &'static str> { + let edits = apply_loro_text_delta_batches( + &mut self.current_text, + &mut self.current_line_starts, + &mut self.current_line_char_starts, + delta_batches, + )?; + if edits.is_empty() { + return Ok(edits); + } + self.translate_cached_anchors(&edits); + self.reshape(); + Ok(edits) + } + + fn translate_cached_anchors(&mut self, edits: &[TextProjectionEdit]) { + for edit in edits { + translate_style_spans(&mut self.current_spans, *edit); + translate_decorations(&mut self.current_decorations, *edit); + translate_inline_adornments(&mut self.current_adornments, *edit); + } + } + + /// Drop journal entries already reflected in a producer frame + /// stamped `generation` — see the `unconfirmed_edits` field docs. + fn prune_unconfirmed_edits(&mut self, generation: u64) { + self.unconfirmed_edits + .retain(|(scalar, _)| *scalar > generation); + } + + fn defer_round_trip_key_if_needed(&mut self, key: ProtocolKey, mods: Modifiers) -> bool { + if self.optimistic_cursor_floor.is_none() && self.deferred_round_trip_keys.is_empty() { + return false; + } + self.cursor_fresh = false; + self.deferred_round_trip_keys.push((key, mods)); + true + } + + fn take_ready_round_trip_keys(&mut self) -> Vec<(ProtocolKey, Modifiers)> { + if self.optimistic_cursor_floor.is_some() || self.deferred_round_trip_keys.is_empty() { + return Vec::new(); + } + self.cursor_fresh = false; + std::mem::take(&mut self.deferred_round_trip_keys) + } + /// Replace the rendered text with `text` and request a redraw. /// Returns `false` when `text` is byte-identical to the current /// rendering (avoids the re-shape cost when an unchanged buffer @@ -664,7 +1008,9 @@ impl State { } self.current_text.clear(); self.current_text.push_str(text); - self.current_line_shapes = minimap_line_shapes(text); + let (line_starts, line_char_starts) = line_offset_tables(text); + self.current_line_starts = line_starts; + self.current_line_char_starts = line_char_starts; self.reshape(); true } @@ -679,7 +1025,7 @@ impl State { /// request the daemon scope styling to the new buffer (return a /// Viewport send-back). /// - `CrdtOp` — apply incremental updates to the doc; text - /// re-extracted. + /// patched from Loro's diff event. /// - `StyleSpans` — replace or merge per the M11.4 dirty-segment /// rule; reshape the rich-text rendering. /// - `Decorations` — same M11.4 shape as `StyleSpans` but for the @@ -709,13 +1055,22 @@ impl State { crdt_snapshot, } => { let doc = loro::LoroDoc::new(); + if let Some(frontend_id) = self.local_frontend_id + && let Err(e) = doc.set_peer_id(frontend_id.0) + { + eprintln!("pmacs-gpu: failed to set snapshot Loro peer id: {e:?}"); + } if let Err(e) = doc.import(&crdt_snapshot) { eprintln!("pmacs-gpu: BufferSnapshot import failed: {e:?}"); return None; } let text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); - let text_len = text.len() as u64; + let (text_delta_batches, text_subscription) = subscribe_loro_text(&doc); + self.loro_text_subscription = None; + self.loro_text_delta_batches = None; self.loro_doc = Some(doc); + self.loro_text_delta_batches = Some(text_delta_batches); + self.loro_text_subscription = Some(text_subscription); self.current_buffer_id = Some(buffer_id); // New buffer ⇒ drop any prior styling/decorations; // the next StyleSpans / Decorations frame for this @@ -730,11 +1085,14 @@ impl State { // next PresenceUpdate / CursorByte arrives. self.peer_presences.clear(); self.own_cursor = None; + self.cursor_fresh = false; + self.optimistic_cursor_floor = None; + self.deferred_round_trip_keys.clear(); + self.unconfirmed_edits.clear(); // New buffer ⇒ back to the top, and force a viewport // re-declaration for the new buffer's scoped range. self.scroll_top = 0; self.last_viewport_sent = None; - let _ = text_len; if !self.set_text(&text) { self.reshape(); } @@ -753,10 +1111,17 @@ impl State { // snapshot will have the ops baked in. return None; }; - if let Err(e) = doc.import(&op.bytes) { - eprintln!("pmacs-gpu: CrdtOp import failed: {e:?}"); - return None; + let delta_batches = self.loro_text_delta_batches.clone(); + if let Some(delta_batches) = delta_batches.as_ref() { + clear_loro_text_delta_batches(delta_batches); } + let import_status = match doc.import(&op.bytes) { + Ok(status) => status, + Err(e) => { + eprintln!("pmacs-gpu: CrdtOp import failed: {e:?}"); + return None; + } + }; // NOTE: `current_spans` / `current_decorations` index // into the *pre-edit* byte positions. The producer's // next render frame (in pmacs core, post-T M11.7 @@ -782,24 +1147,83 @@ impl State { // inlay store stale, and the producer sends one empty // replacement to clear cached virtual text until a // fresh `textDocument/inlayHint` response arrives. - let text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); - self.set_text(&text); - // An edit shifts byte positions, so the scoped viewport's - // byte range moves even at the same scroll position; - // re-declare it so the producer styles the right bytes - // (the generation bump already forces a full resync, so - // this is no extra round trip). - self.viewport_send_if_changed(buffer_id) + let delta_batches = delta_batches + .as_ref() + .map(drain_loro_text_delta_batches) + .unwrap_or_default(); + if delta_batches.is_empty() { + if !import_status.success.is_empty() { + let text = self + .loro_doc + .as_ref() + .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); + if let Some(text) = text { + self.set_text(&text); + } + self.unconfirmed_edits.clear(); + } + } else { + match self.apply_loro_text_delta_batches(&delta_batches) { + Ok(edits) => { + // A daemon-originated edit shifts the text + // under any still-unconfirmed optimistic + // inserts. Rebase the journal's anchors so + // frames that include this edit (but not + // ours) translate correctly. Journal + // entries are pure inserts (the optimistic + // path only inserts), so anchor == start. + for incoming in &edits { + for (_, pending) in &mut self.unconfirmed_edits { + pending.start = + translate_byte_position(pending.start, *incoming); + pending.old_end = pending.start; + } + } + } + Err(reason) => { + eprintln!( + "pmacs-gpu: incremental CRDT text update failed ({reason}); \ + falling back to full materialization" + ); + let text = self + .loro_doc + .as_ref() + .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); + if let Some(text) = text { + self.set_text(&text); + } + self.unconfirmed_edits.clear(); + } + } + } + // Local typing usually shifts only the viewport's end + // byte while the top visible source line stays fixed. + // The declared range includes overscan, and the daemon's + // generation bump already forces a full style resync, so + // re-declaring on every byte is mostly write amplification. + // Re-declare here only if the viewport origin moved (for + // example because an edit before `scroll_top` shifted the + // top line); scroll/resize/snapshot still send exact ranges. + self.viewport_send_if_origin_changed(buffer_id) } InstanceMessage::StyleSpans { buffer_id, - generation: _, + generation, full, segments, } => { if self.current_buffer_id != Some(buffer_id) { return None; } + // The producer computed this frame against the daemon + // text at `generation` (its CRDT version scalar). Any + // optimistic local inserts the daemon hadn't integrated + // yet shift the frame's byte ranges; translate them so + // the repaint doesn't flash every color after the + // cursor a few bytes left of its glyphs for one frame + // (the typing shimmer). + self.prune_unconfirmed_edits(generation); + let segments = translate_style_segments(segments, &self.unconfirmed_edits); if full { self.replace_style_spans(segments); } else { @@ -810,13 +1234,16 @@ impl State { } InstanceMessage::Decorations { buffer_id, - generation: _, + generation, full, segments, } => { if self.current_buffer_id != Some(buffer_id) { return None; } + // Same staleness translation as the StyleSpans arm. + self.prune_unconfirmed_edits(generation); + let segments = translate_decoration_segments(segments, &self.unconfirmed_edits); // Only diagnostic decorations affect the *rich text* // (they override glyph fg in `projected_rich_chunks`); // background kinds (Selection / CurrentLine / Search) @@ -907,10 +1334,30 @@ impl State { self.current_buffer_id == Some(buffer_id) ); } + if let Some(floor) = self.optimistic_cursor_floor + && floor.buffer_id == buffer_id + && byte_pos < floor.byte + { + if debug_input() { + eprintln!( + "pmacs-gpu cursor: ignored stale optimistic rewind \ + buf={buffer_id:?} byte={byte_pos} floor={}", + floor.byte + ); + } + return None; + } + if self + .optimistic_cursor_floor + .is_some_and(|floor| floor.buffer_id != buffer_id || byte_pos >= floor.byte) + { + self.optimistic_cursor_floor = None; + } self.own_cursor = Some(OwnCursor { buffer_id, byte: byte_pos, }); + self.cursor_fresh = self.current_buffer_id == Some(buffer_id); // Session S1 — keep the caret on screen (Q#S2). When the // cursor leaves the visible slice (arrows past an edge, // PageUp/Down), scroll to follow it, re-shape the new @@ -925,6 +1372,10 @@ impl State { self.window.request_redraw(); None } + InstanceMessage::DispatchIdle { idle } => { + self.dispatch_idle = idle; + None + } _ => None, } } @@ -946,6 +1397,23 @@ impl State { }) } + /// Edit-path variant of [`Self::viewport_send_if_changed`]. For + /// ordinary insertion/deletion inside the visible slice, only the + /// end byte moves; sending that on every `CrdtOp` doubles the + /// frontend-to-daemon write traffic while the producer already has + /// a CRDT generation transition to trigger a full viewport resync. + /// If the start byte moves, the top visible line itself shifted, so + /// the daemon needs a fresh declaration. + fn viewport_send_if_origin_changed(&mut self, buffer_id: BufferId) -> Option { + let Some((last_start, _)) = self.last_viewport_sent else { + return self.viewport_send_if_changed(buffer_id); + }; + if last_start == self.view_range.0 { + return None; + } + self.viewport_send_if_changed(buffer_id) + } + /// Adjust `scroll_top` so the own cursor's source line is within the /// visible window (Q#S2). Returns whether `scroll_top` changed (in /// which case the caller re-shapes + re-declares the viewport). @@ -956,7 +1424,7 @@ impl State { if self.current_buffer_id != Some(own.buffer_id) { return false; } - let line_starts = line_byte_offsets(&self.current_text); + let line_starts = &self.current_line_starts; let cursor = own.byte.min(self.current_text.len() as u64); // Cursor's source line = largest i with line_starts[i] <= cursor. let cursor_line = line_starts @@ -988,6 +1456,7 @@ impl State { { return; } + self.current_line_shapes = minimap_line_shapes(&self.current_text); self.current_summary = Some(FileStyleSummaryState { generation, lines }); self.window.request_redraw(); } @@ -1157,7 +1626,7 @@ impl State { /// boundaries (cosmic-text splits `BufferLine`s on `\n`, so a /// mid-line slice would corrupt the first/last line). fn visible_byte_range(&self) -> (u64, u64) { - let line_starts = line_byte_offsets(&self.current_text); + let line_starts = &self.current_line_starts; let n = line_starts.len(); let top = self.scroll_top.min(n.saturating_sub(1)); let span = estimated_visible_lines(self.config.height).max(1) + SCROLL_OVERSCAN; @@ -1928,6 +2397,37 @@ fn debug_frame() -> bool { *FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_FRAME").is_some()) } +/// One-shot env flag: `PMACS_GPU_DEBUG_APPLY=1` logs how long the +/// main thread spends applying each inbound daemon message. This +/// separates CRDT text patching, style replacement, and cursor updates +/// from the later `render()` timings. +fn debug_apply() -> bool { + static FLAG: std::sync::OnceLock = std::sync::OnceLock::new(); + *FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_APPLY").is_some()) +} + +fn instance_message_label(msg: &InstanceMessage) -> &'static str { + match msg { + InstanceMessage::CellDelta { .. } => "CellDelta", + InstanceMessage::Cursor(_) => "Cursor", + InstanceMessage::ModeLine(_) => "ModeLine", + InstanceMessage::Signal(_) => "Signal", + InstanceMessage::Goodbye(_) => "Goodbye", + InstanceMessage::CrdtOp { .. } => "CrdtOp", + InstanceMessage::PresenceUpdate { .. } => "PresenceUpdate", + InstanceMessage::BufferSnapshot { .. } => "BufferSnapshot", + InstanceMessage::CursorByte { .. } => "CursorByte", + InstanceMessage::StyleSpans { .. } => "StyleSpans", + InstanceMessage::Decorations { .. } => "Decorations", + InstanceMessage::InlineAdornments { .. } => "InlineAdornments", + InstanceMessage::FileStyleSummary { .. } => "FileStyleSummary", + InstanceMessage::BlockAdornments { .. } => "BlockAdornments", + InstanceMessage::FoldState { .. } => "FoldState", + InstanceMessage::ResourceOffer { .. } => "ResourceOffer", + InstanceMessage::DispatchIdle { .. } => "DispatchIdle", + } +} + /// One-shot env flag: `PMACS_GPU_DEBUG_INPUT=1` logs the input path — /// keys sent and `CursorByte` received (with the buffer it targets vs /// the buffer being displayed). The buffer comparison is the B1 @@ -1978,6 +2478,7 @@ fn translate_key( NamedKey::Delete => ProtocolKey::Delete, NamedKey::Insert => ProtocolKey::Insert, NamedKey::Tab => ProtocolKey::Tab, + NamedKey::Space => ProtocolKey::Char(' '), _ => return None, }, Key::Character(s) => ProtocolKey::Char(s.chars().next()?), @@ -2014,10 +2515,7 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { if is_motion_key(key) { return true; } - let chord = mods.contains(Modifiers::CTRL) - || mods.contains(Modifiers::ALT) - || mods.contains(Modifiers::META); - if chord { + if !is_plain_text_modifiers(mods) { return false; } matches!( @@ -2030,6 +2528,13 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { ) } +fn is_plain_text_modifiers(mods: Modifiers) -> bool { + !mods.contains(Modifiers::CTRL) + && !mods.contains(Modifiers::ALT) + && !mods.contains(Modifiers::META) + && !mods.contains(Modifiers::HYPER) +} + /// Clip a whole-file byte range `[start, end)` to the visible slice /// `[vstart, vend)` and rebase it into slice coordinates (subtract /// `vstart`). Returns `None` when the range is disjoint from the slice. @@ -2044,6 +2549,117 @@ fn clip_rebase_range(start: u64, end: u64, vstart: u64, vend: u64) -> Option<(u6 Some((s - vstart, e - vstart)) } +/// Sum of the doc's per-peer version-vector counters — the **same +/// formula** as the daemon's `CrdtState::version_scalar`, which is +/// what the producer stamps into `StyleSpans` / `Decorations` +/// `generation`. The sum is integration-order independent, so once +/// both replicas hold the same set of ops the scalars are equal; +/// that is what makes frame generations comparable against locally +/// computed values in `unconfirmed_edits`. +fn loro_version_scalar(doc: &loro::LoroDoc) -> u64 { + doc.oplog_vv() + .values() + .map(|counter| u64::try_from(*counter).unwrap_or(0)) + .sum() +} + +/// Translate one incoming `StyleSpans` frame's segments through the +/// optimistic edits the daemon had not yet integrated when it +/// computed the frame. Ranges that a (defensive) delete fully +/// removes drop out. +fn translate_style_segments( + segments: Vec, + edits: &[(u64, TextProjectionEdit)], +) -> Vec { + if edits.is_empty() { + return segments; + } + segments + .into_iter() + .filter_map(|seg| { + let mut range = seg.range; + let mut spans = seg.spans; + for (_, edit) in edits { + range = translate_byte_range(range, *edit)?; + spans = spans + .into_iter() + .filter_map(|mut sp| { + sp.range = translate_byte_range(sp.range, *edit)?; + Some(sp) + }) + .collect(); + } + Some(StyleSegment { range, spans }) + }) + .collect() +} + +/// `Decorations` twin of [`translate_style_segments`]. +fn translate_decoration_segments( + segments: Vec, + edits: &[(u64, TextProjectionEdit)], +) -> Vec { + if edits.is_empty() { + return segments; + } + segments + .into_iter() + .filter_map(|seg| { + let mut range = seg.range; + let mut decorations = seg.decorations; + for (_, edit) in edits { + range = translate_byte_range(range, *edit)?; + decorations = decorations + .into_iter() + .filter_map(|mut d| { + d.range = translate_byte_range(d.range, *edit)?; + Some(d) + }) + .collect(); + } + Some(DecorationSegment { range, decorations }) + }) + .collect() +} + +fn subscribe_loro_text(doc: &loro::LoroDoc) -> (LoroTextDeltaBatches, loro::Subscription) { + let text = doc.get_text(LORO_TEXT_CONTAINER); + let delta_batches = Arc::new(Mutex::new(Vec::>::new())); + let captured_batches = Arc::clone(&delta_batches); + let subscription = doc.subscribe( + &text.id(), + Arc::new(move |event| { + let mut guard = captured_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for event in event.events { + if let Some(delta) = event.diff.as_text() + && !delta.is_empty() + { + guard.push(delta.clone()); + } + } + }), + ); + (delta_batches, subscription) +} + +fn clear_loro_text_delta_batches(delta_batches: &LoroTextDeltaBatches) { + delta_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); +} + +fn drain_loro_text_delta_batches( + delta_batches: &LoroTextDeltaBatches, +) -> Vec> { + let mut guard = delta_batches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + std::mem::take(&mut *guard) +} + /// Largest char-boundary `<= index` (stable equivalent of the unstable /// `str::floor_char_boundary`). Used to snap externally-supplied byte /// offsets to valid slice points so a stale, mid-codepoint offset can't @@ -2063,13 +2679,317 @@ fn floor_char_boundary(text: &str, index: usize) -> usize { /// line (index 0 = byte 0). Indexed by cosmic-text's /// `LayoutRun::line_i` to rebase line-relative glyph offsets. fn line_byte_offsets(text: &str) -> Vec { + line_offset_tables(text).0 +} + +fn line_offset_tables(text: &str) -> (Vec, Vec) { let mut starts = vec![0u64]; - for (i, b) in text.bytes().enumerate() { - if b == b'\n' { - starts.push(i as u64 + 1); + let mut char_starts = vec![0u64]; + let mut chars_seen = 0u64; + for (byte, ch) in text.char_indices() { + chars_seen += 1; + if ch == '\n' { + starts.push(byte as u64 + 1); + char_starts.push(chars_seen); } } - starts + (starts, char_starts) +} + +fn byte_offset_for_char_offset( + text: &str, + line_starts: &[u64], + line_char_starts: &[u64], + char_offset: usize, +) -> Option { + if line_starts.len() != line_char_starts.len() { + return None; + } + let line = line_char_starts + .partition_point(|&start| start <= char_offset as u64) + .saturating_sub(1); + let byte_start = *line_starts.get(line)? as usize; + let char_start = *line_char_starts.get(line)? as usize; + let mut byte = byte_start; + for _ in 0..char_offset.checked_sub(char_start)? { + let ch = text.get(byte..)?.chars().next()?; + byte += ch.len_utf8(); + } + Some(byte) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TextProjectionEdit { + start: u64, + old_end: u64, + inserted_len: u64, +} + +fn apply_loro_text_delta_batches( + text: &mut String, + line_starts: &mut Vec, + line_char_starts: &mut Vec, + delta_batches: &[Vec], +) -> Result, &'static str> { + let mut edits = Vec::new(); + for delta in delta_batches { + apply_loro_text_delta_batch(text, line_starts, line_char_starts, delta, &mut edits)?; + } + Ok(edits) +} + +fn apply_loro_text_delta_batch( + text: &mut String, + line_starts: &mut Vec, + line_char_starts: &mut Vec, + delta: &[loro::TextDelta], + edits: &mut Vec, +) -> Result<(), &'static str> { + let mut cursor_char = 0usize; + for op in delta { + match op { + loro::TextDelta::Retain { retain, .. } => { + cursor_char = cursor_char + .checked_add(*retain) + .ok_or("retain offset overflow")?; + } + loro::TextDelta::Insert { insert, .. } => { + if insert.is_empty() { + continue; + } + let start_byte = + byte_offset_for_char_offset(text, line_starts, line_char_starts, cursor_char) + .ok_or("insert offset outside current text")?; + replace_text_range_with_line_updates( + text, + line_starts, + line_char_starts, + start_byte, + start_byte, + cursor_char, + cursor_char, + insert, + )?; + edits.push(TextProjectionEdit { + start: start_byte as u64, + old_end: start_byte as u64, + inserted_len: insert.len() as u64, + }); + cursor_char = cursor_char + .checked_add(insert.chars().count()) + .ok_or("insert offset overflow")?; + } + loro::TextDelta::Delete { delete } => { + if *delete == 0 { + continue; + } + let start_char = cursor_char; + let end_char = cursor_char + .checked_add(*delete) + .ok_or("delete offset overflow")?; + let start_byte = + byte_offset_for_char_offset(text, line_starts, line_char_starts, start_char) + .ok_or("delete start outside current text")?; + let end_byte = + byte_offset_for_char_offset(text, line_starts, line_char_starts, end_char) + .ok_or("delete end outside current text")?; + replace_text_range_with_line_updates( + text, + line_starts, + line_char_starts, + start_byte, + end_byte, + start_char, + end_char, + "", + )?; + edits.push(TextProjectionEdit { + start: start_byte as u64, + old_end: end_byte as u64, + inserted_len: 0, + }); + } + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn replace_text_range_with_line_updates( + text: &mut String, + line_starts: &mut Vec, + line_char_starts: &mut Vec, + start_byte: usize, + end_byte: usize, + start_char: usize, + end_char: usize, + insert: &str, +) -> Result<(), &'static str> { + if line_starts.len() != line_char_starts.len() { + return Err("line offset tables have different lengths"); + } + if start_byte > end_byte || end_byte > text.len() { + return Err("replacement byte range is outside current text"); + } + if start_char > end_char { + return Err("replacement char range is inverted"); + } + if !text.is_char_boundary(start_byte) || !text.is_char_boundary(end_byte) { + return Err("replacement byte range is not on char boundaries"); + } + + let start_line = line_starts + .partition_point(|&start| start <= start_byte as u64) + .saturating_sub(1); + let remove_start = start_line + 1; + let remove_end = line_starts.partition_point(|&start| start <= end_byte as u64); + let (inserted_line_starts, inserted_line_char_starts) = + inserted_line_offsets(insert, start_byte, start_char); + let inserted_line_count = inserted_line_starts.len(); + let byte_delta = signed_usize_delta(insert.len(), end_byte - start_byte)?; + let char_delta = signed_usize_delta(insert.chars().count(), end_char - start_char)?; + + text.replace_range(start_byte..end_byte, insert); + line_starts.splice(remove_start..remove_end, inserted_line_starts); + line_char_starts.splice(remove_start..remove_end, inserted_line_char_starts); + let suffix_start = remove_start + inserted_line_count; + for start in line_starts.iter_mut().skip(suffix_start) { + shift_u64(start, byte_delta); + } + for start in line_char_starts.iter_mut().skip(suffix_start) { + shift_u64(start, char_delta); + } + Ok(()) +} + +fn inserted_line_offsets( + insert: &str, + start_byte: usize, + start_char: usize, +) -> (Vec, Vec) { + let mut line_starts = Vec::new(); + let mut line_char_starts = Vec::new(); + let mut chars_seen = 0usize; + for (rel_byte, ch) in insert.char_indices() { + chars_seen += 1; + if ch == '\n' { + line_starts.push((start_byte + rel_byte + 1) as u64); + line_char_starts.push((start_char + chars_seen) as u64); + } + } + (line_starts, line_char_starts) +} + +fn shift_u64(value: &mut u64, delta: i64) { + if delta >= 0 { + *value = value.saturating_add(delta as u64); + } else { + *value = value.saturating_sub(delta.unsigned_abs()); + } +} + +fn signed_usize_delta(new_len: usize, old_len: usize) -> Result { + let new_len = i64::try_from(new_len).map_err(|_| "new length exceeds i64")?; + let old_len = i64::try_from(old_len).map_err(|_| "old length exceeds i64")?; + Ok(new_len - old_len) +} + +fn translate_style_spans(spans: &mut Vec, edit: TextProjectionEdit) { + let mut translated = Vec::with_capacity(spans.len()); + for mut span in spans.drain(..) { + if let Some(range) = translate_byte_range(span.range, edit) { + span.range = range; + translated.push(span); + } + } + *spans = translated; +} + +fn translate_decorations(decorations: &mut Vec, edit: TextProjectionEdit) { + let mut translated = Vec::with_capacity(decorations.len()); + for mut decoration in decorations.drain(..) { + if let Some(range) = translate_byte_range(decoration.range, edit) { + decoration.range = range; + translated.push(decoration); + } + } + *decorations = translated; +} + +fn translate_inline_adornments(adornments: &mut [InlineAdornment], edit: TextProjectionEdit) { + for adornment in adornments { + adornment.at = translate_byte_position(adornment.at, edit); + } +} + +fn translate_byte_range(range: ByteRange, edit: TextProjectionEdit) -> Option { + let start = translate_range_start(range.start, edit); + let end = translate_range_end(range.end, edit); + (start < end).then_some(ByteRange { start, end }) +} + +fn translate_range_start(pos: u64, edit: TextProjectionEdit) -> u64 { + if edit.old_end == edit.start { + if pos >= edit.start { + pos.saturating_add(edit.inserted_len) + } else { + pos + } + } else if pos <= edit.start { + pos + } else if pos >= edit.old_end { + shift_position(pos, edit) + } else { + edit.start + } +} + +fn translate_range_end(pos: u64, edit: TextProjectionEdit) -> u64 { + if edit.old_end == edit.start { + // `>=` (not `>`): a range ending exactly at a pure-insert + // point *extends over* the inserted text. Typing at the end + // of a token is the dominant editing case, and inheriting the + // preceding span's color keeps the new char stably colored + // instead of blinking default-white until the next parse + // settles. (The start counterpart keeps `>=` shifting right, + // so a following span never overlaps the extension.) + if pos >= edit.start { + pos.saturating_add(edit.inserted_len) + } else { + pos + } + } else if pos <= edit.start { + pos + } else if pos >= edit.old_end { + shift_position(pos, edit) + } else { + edit.start.saturating_add(edit.inserted_len) + } +} + +fn translate_byte_position(pos: u64, edit: TextProjectionEdit) -> u64 { + if edit.old_end == edit.start { + if pos >= edit.start { + pos.saturating_add(edit.inserted_len) + } else { + pos + } + } else if pos <= edit.start { + pos + } else if pos >= edit.old_end { + shift_position(pos, edit) + } else { + edit.start.saturating_add(edit.inserted_len) + } +} + +fn shift_position(pos: u64, edit: TextProjectionEdit) -> u64 { + let old_len = edit.old_end.saturating_sub(edit.start); + if edit.inserted_len >= old_len { + pos.saturating_add(edit.inserted_len - old_len) + } else { + pos.saturating_sub(old_len - edit.inserted_len) + } } /// Byte range `[start, end)` of the source line containing `cursor`: @@ -2498,6 +3418,10 @@ mod tests { let (bk, _) = translate_key(&WKey::Named(NamedKey::Backspace), none).expect("bksp maps"); assert_eq!(bk, ProtocolKey::Backspace); assert!(!is_motion_key(bk)); + + let (space, _) = translate_key(&WKey::Named(NamedKey::Space), none).expect("space maps"); + assert_eq!(space, ProtocolKey::Char(' ')); + assert!(!is_motion_key(space)); } #[test] @@ -2523,6 +3447,10 @@ mod tests { // Ctrl/Alt/Meta + a non-motion key is a chord — withheld in B2. assert!(!should_forward_key(ProtocolKey::Char('x'), ctrl)); assert!(!should_forward_key(ProtocolKey::Char('f'), Modifiers::ALT)); + assert!(!should_forward_key( + ProtocolKey::Char('h'), + Modifiers::HYPER + )); // Motion keys forward regardless of modifiers (C-Left = word-left). assert!(should_forward_key(ProtocolKey::Left, ctrl)); @@ -2587,6 +3515,267 @@ mod tests { assert_eq!(line_byte_offsets(""), vec![0]); } + #[test] + fn line_char_offsets_track_unicode_line_starts() { + let text = "aé\n😀b\n"; + let (line_starts, line_char_starts) = line_offset_tables(text); + assert_eq!(line_starts, vec![0, 4, 10]); + assert_eq!(line_char_starts, vec![0, 3, 6]); + } + + #[test] + fn byte_offset_for_char_offset_scans_only_within_line() { + let text = "aé\n😀b"; + let (line_starts, line_char_starts) = line_offset_tables(text); + assert_eq!( + byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 0), + Some(0) + ); + assert_eq!( + byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 2), + Some(3) + ); + assert_eq!( + byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 3), + Some(4) + ); + assert_eq!( + byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 4), + Some(8) + ); + } + + #[test] + fn loro_text_delta_batch_inserts_multibyte_text_and_updates_lines() { + let mut text = "aé\nb".to_owned(); + let (mut line_starts, mut line_char_starts) = line_offset_tables(&text); + let delta = vec![ + loro::TextDelta::Retain { + retain: 3, + attributes: None, + }, + loro::TextDelta::Insert { + insert: "😀\n".to_owned(), + attributes: None, + }, + ]; + + let mut edits = Vec::new(); + apply_loro_text_delta_batch( + &mut text, + &mut line_starts, + &mut line_char_starts, + &delta, + &mut edits, + ) + .expect("delta applies"); + + assert_eq!(text, "aé\n😀\nb"); + assert_eq!((line_starts, line_char_starts), line_offset_tables(&text)); + assert_eq!( + edits, + vec![TextProjectionEdit { + start: 4, + old_end: 4, + inserted_len: "😀\n".len() as u64, + }] + ); + } + + #[test] + fn loro_text_delta_batch_deletes_across_unicode_lines() { + let mut text = "aé\n😀\nb".to_owned(); + let (mut line_starts, mut line_char_starts) = line_offset_tables(&text); + let delta = vec![ + loro::TextDelta::Retain { + retain: 1, + attributes: None, + }, + loro::TextDelta::Delete { delete: 3 }, + ]; + + let mut edits = Vec::new(); + apply_loro_text_delta_batch( + &mut text, + &mut line_starts, + &mut line_char_starts, + &delta, + &mut edits, + ) + .expect("delta applies"); + + assert_eq!(text, "a\nb"); + assert_eq!((line_starts, line_char_starts), line_offset_tables(&text)); + assert_eq!( + edits, + vec![TextProjectionEdit { + start: 1, + old_end: 8, + inserted_len: 0, + }] + ); + } + + #[test] + fn cached_style_ranges_translate_through_insertions() { + let edit = TextProjectionEdit { + start: 5, + old_end: 5, + inserted_len: 3, + }; + + assert_eq!( + translate_byte_range(ByteRange { start: 10, end: 14 }, edit), + Some(ByteRange { start: 13, end: 17 }), + "ranges after the insert shift right" + ); + assert_eq!( + translate_byte_range(ByteRange { start: 2, end: 10 }, edit), + Some(ByteRange { start: 2, end: 13 }), + "ranges containing the insert expand" + ); + assert_eq!( + translate_byte_range(ByteRange { start: 2, end: 5 }, edit), + Some(ByteRange { start: 2, end: 8 }), + "ranges ending exactly at the insert boundary extend over the typed \ + text — typed chars inherit the preceding token's color until the \ + next authoritative frame" + ); + } + + #[test] + fn optimistic_insert_text_covers_plain_chars_enter_and_tab() { + let mut buf = [0u8; 4]; + let none = Modifiers::NONE; + let shift = Modifiers::SHIFT; + let ctrl = Modifiers::CTRL; + + assert_eq!( + optimistic_insert_text(ProtocolKey::Char('a'), none, &mut buf), + Some("a") + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Char('É'), shift, &mut buf), + Some("É"), + "shifted printable chars stay optimistic (shift is how uppercase arrives)" + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Enter, none, &mut buf), + Some("\n"), + "RET is bound to buffer.newline = insert_char(10): identical to a self-insert" + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Tab, none, &mut buf), + Some("\t"), + "TAB is bound to buffer.tab = insert_char(9): identical to a self-insert" + ); + + // Modified Enter/Tab and chords round-trip — a keymap may bind + // S-RET / C-TAB to anything. + assert_eq!( + optimistic_insert_text(ProtocolKey::Enter, shift, &mut buf), + None + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Tab, ctrl, &mut buf), + None + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Char('x'), ctrl, &mut buf), + None + ); + // Deletions and motion still round-trip. + assert_eq!( + optimistic_insert_text(ProtocolKey::Backspace, none, &mut buf), + None + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Left, none, &mut buf), + None + ); + } + + #[test] + fn incoming_frames_translate_through_unconfirmed_edits() { + // A frame computed at daemon generation G arrives while one + // local optimistic insert (scalar G+1: 3 bytes at byte 5) is + // still unconfirmed: the frame's ranges must shift through it. + let unconfirmed = vec![( + 11u64, + TextProjectionEdit { + start: 5, + old_end: 5, + inserted_len: 3, + }, + )]; + let segments = vec![StyleSegment { + range: ByteRange { start: 0, end: 20 }, + spans: vec![ + StyleSpan { + range: ByteRange { start: 2, end: 4 }, + style: CellStyle::default(), + }, + StyleSpan { + range: ByteRange { start: 10, end: 14 }, + style: CellStyle::default(), + }, + ], + }]; + + let translated = translate_style_segments(segments, &unconfirmed); + assert_eq!(translated.len(), 1); + assert_eq!( + translated[0].range, + ByteRange { start: 0, end: 23 }, + "segment range expands over the unconfirmed insert" + ); + assert_eq!( + translated[0].spans[0].range, + ByteRange { start: 2, end: 4 }, + "spans before the insert are untouched" + ); + assert_eq!( + translated[0].spans[1].range, + ByteRange { start: 13, end: 17 }, + "spans after the insert shift right by its length" + ); + + // With no unconfirmed edits the frame passes through as-is. + let untouched = translate_style_segments( + vec![StyleSegment { + range: ByteRange { start: 0, end: 20 }, + spans: Vec::new(), + }], + &[], + ); + assert_eq!(untouched[0].range, ByteRange { start: 0, end: 20 }); + } + + #[test] + fn cached_style_ranges_translate_through_deletions() { + let edit = TextProjectionEdit { + start: 5, + old_end: 9, + inserted_len: 0, + }; + + assert_eq!( + translate_byte_range(ByteRange { start: 12, end: 16 }, edit), + Some(ByteRange { start: 8, end: 12 }), + "ranges after the deletion shift left" + ); + assert_eq!( + translate_byte_range(ByteRange { start: 3, end: 12 }, edit), + Some(ByteRange { start: 3, end: 8 }), + "ranges spanning the deletion shrink" + ); + assert_eq!( + translate_byte_range(ByteRange { start: 6, end: 8 }, edit), + None, + "ranges fully removed by the deletion drop" + ); + } + #[test] fn source_line_range_handles_empty_and_leading_newline() { assert_eq!(source_line_range("", 0), (0, 0)); From 799a45db063c048c850f5298bfbc874718ba6c35 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 10:49:51 -0400 Subject: [PATCH 13/19] LSP didChange debounce + queued process stdin writer (typing perf) Full-document didChange went out per keystroke: three O(file) copies, O(file) JSON, and a BLOCKING pipe write on the daemon main thread (Linux pipe buffers are 64KiB; a 240KB notification stalls the frame loop until the langserver drains). The dominant daemon-side typing cost on large files, and freeze-class when a server stops reading. - lsp.lua: the after-edit hook now bumps the version, marks the cached render families stale (new _mark_document_stale binding, so stale suppression stays keystroke-accurate), and records the buffer dirty. The coalesced send fires on the async tick after 75ms of quiet, or at most 400ms behind during continuous typing. Anything that consults the server flushes first (attached_for_active, repull_for_attachments, pull_inlay_hints_quiet) so requests and position-encoding conversion never see stale text. Versions may skip values; LSP only requires they increase. - Inlay hints re-pull at flush cadence: they're pull-model, nothing re-requested them after edits, so hints died on the first keystroke and never returned. - process.rs StdinWriter: a per-generation writer thread owns the child's stdin; write_stdin queues and never blocks (64MiB budget converts a wedged child into an error); close_stdin drains then EOFs, preserving the MCP flush-then-EOF contract. - pmacs.editor.monotonic_ms + pmacs.lsp._flush_did_changes bindings; acceptance test pins burst-coalescing, flush-on-demand, and the quiet-window tick flush. Co-Authored-By: Claude Fable 5 --- builtin/runtime/lsp.lua | 124 ++++++++++++++++++++++- src/lsp.rs | 45 +++++---- src/lua_bindings.rs | 33 +++++++ src/process.rs | 211 ++++++++++++++++++++++++++++++++++++---- tests/m4_acceptance.rs | 100 +++++++++++++++++++ 5 files changed, 471 insertions(+), 42 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 8cae97c..9699367 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -233,6 +233,84 @@ local function buffer_text(buf) return buf:slice(0, buf:len()) end +-- didChange coalescing (typing perf) ----------------------------------------- +-- +-- Document sync is full-text, so each `textDocument/didChange` ships +-- the entire buffer. Sending one per keystroke cost three O(file) +-- copies plus an O(file) JSON write to the server pipe *per typed +-- character* — the dominant daemon-side typing cost on large files. +-- The after-edit hook now only bumps the version, marks the cached +-- render families stale (cheap), and records the buffer as dirty; +-- the actual notification ships from the async tick once the buffer +-- has been quiet for DID_CHANGE_QUIET_MS, or unconditionally once +-- the oldest unsent edit is DID_CHANGE_MAX_LAG_MS old (so the server +-- keeps converging during continuous typing). Versions may skip +-- values across a coalesced burst; LSP only requires that they +-- increase. Anything that asks the server about a document flushes +-- it first so no request is answered against stale text. +local DID_CHANGE_QUIET_MS = 75 +local DID_CHANGE_MAX_LAG_MS = 400 + +-- Dirty buffers: key (tostring(buf)) -> { +-- rec = the attachment record the edits belong to, +-- first_ms = monotonic time of the oldest unsent edit, +-- last_ms = monotonic time of the newest unsent edit, +-- } +local pending_did_change = {} + +-- Forward declaration — defined below (needs helpers that follow); +-- `flush_did_change` re-pulls inlay hints after each coalesced send. +local pull_inlay_hints_quiet + +local function flush_did_change(key) + local pending = pending_did_change[key] + if not pending then return end + pending_did_change[key] = nil + local rec = pending.rec + -- The attachment may have been torn down or replaced (server + -- crash -> re-attach) since the edit was recorded; only the live + -- record's server should hear about the buffer. + if attachments[key] ~= rec then return end + local ok, text = pcall(buffer_text, rec.buffer) + if not ok then return end + pcall(pmacs.lsp.did_change, rec.server, rec.uri, rec.version, text) + -- Inlay hints are pull-model: the store's stale flag (set per edit) + -- only clears on a fresh `textDocument/inlayHint` response, and the + -- server never volunteers one. Re-request at flush cadence so + -- hints come back shortly after each pause instead of staying + -- suppressed until the next attach/refresh. The request is + -- supersede-keyed per (server, method, uri), so a burst of flushes + -- cancels its own predecessors rather than piling up. + pcall(pull_inlay_hints_quiet, rec) +end + +local function flush_did_change_for(rec) + if rec and rec.buffer then flush_did_change(tostring(rec.buffer)) end +end + +local function flush_all_did_changes() + for key in pairs(pending_did_change) do + flush_did_change(key) + end +end + +local function flush_due_did_changes() + if next(pending_did_change) == nil then return end + local now = pmacs.editor.monotonic_ms() + for key, pending in pairs(pending_did_change) do + if now - pending.last_ms >= DID_CHANGE_QUIET_MS + or now - pending.first_ms >= DID_CHANGE_MAX_LAG_MS then + flush_did_change(key) + end + end +end + +-- Exposed for tests and for glue that must synchronize the server's +-- document view before an out-of-band operation (e.g. a save hook). +function pmacs.lsp._flush_did_changes() + flush_all_did_changes() +end + local function document_end_position(text) local line, col = 0, 0 for i = 1, #text do @@ -350,9 +428,16 @@ local function server_supports_inlay_hints(sid) return caps.inlayHintProvider ~= nil and caps.inlayHintProvider ~= false end -local function pull_inlay_hints_quiet(rec) +-- Assigns the forward-declared local above (so `flush_did_change` +-- can re-pull); a fresh `local function` here would shadow it. +function pull_inlay_hints_quiet(rec) if not rec or not server_is_initialized(rec.server) then return end if not server_supports_inlay_hints(rec.server) then return end + -- The server must see the current text before being asked to + -- compute positions against it (didChange is debounced). A no-op + -- when called from `flush_did_change` itself (the pending entry is + -- removed before the send), so this cannot recurse. + flush_did_change_for(rec) local end_line, end_col = document_end_position(buffer_text(rec.buffer)) pmacs.async(function() pcall(function() @@ -379,7 +464,12 @@ local function attach_buffer(buf) local key = tostring(buf) local existing = attachments[key] if existing and server_is_live(existing.server) then return existing end - if existing then attachments[key] = nil end + if existing then + attachments[key] = nil + -- Unsent edits targeted the dead attachment; the did_open below + -- carries the full current text, superseding them. + pending_did_change[key] = nil + end local language = active_buffer_language() if not language then return nil end -- Path resolved before spawn so the server's `rootUri` can be @@ -430,7 +520,16 @@ end local function attached_for_active() local buf = pmacs.window.buffer() if not buf then return nil end - return attachments[tostring(buf)] or attach_buffer(buf) + local key = tostring(buf) + local rec = attachments[key] + if rec then + -- Every interactive command resolves its attachment here before + -- issuing requests; flushing now means the server answers those + -- requests against the current text (didChange is debounced). + flush_did_change(key) + return rec + end + return attach_buffer(buf) end -- Hooks -------------------------------------------------------------------- @@ -442,10 +541,21 @@ end) pmacs.hook.add("buffer.after-edit", function() local buf = pmacs.window.buffer() if not buf then return end - local rec = attachments[tostring(buf)] + local key = tostring(buf) + local rec = attachments[key] if not rec then return end rec.version = rec.version + 1 - pcall(pmacs.lsp.did_change, rec.server, rec.uri, rec.version, active_buffer_text()) + -- Stale suppression must stay keystroke-accurate even though the + -- O(file) didChange send below is coalesced: render families + -- anchored to pre-edit positions are hidden from this edit on. + pcall(pmacs.lsp._mark_document_stale, rec.uri) + local now = pmacs.editor.monotonic_ms() + local pending = pending_did_change[key] + if pending and pending.rec == rec then + pending.last_ms = now + else + pending_did_change[key] = { rec = rec, first_ms = now, last_ms = now } + end end) -- Async request surface (T M4.5 async bridge). The Rust manager @@ -658,6 +768,9 @@ end local function repull_for_attachments(sid, request_fn) for _, rec in pairs(attachments) do if rec.server == sid and rec.uri then + -- Server-initiated repulls (diagnostics refresh, semantic + -- tokens refresh) must also see the latest text first. + flush_did_change_for(rec) pcall(request_fn, sid, rec.uri, rec) end end @@ -994,6 +1107,7 @@ if pmacs._async and pmacs._async.tick then pmacs._async.tick = function(...) local ret = _prior_async_tick(...) pcall(handle_server_requests) + pcall(flush_due_did_changes) return ret end end diff --git a/src/lsp.rs b/src/lsp.rs index a8de7e2..e777c98 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -3044,24 +3044,7 @@ impl LspManager { let uri = uri.into(); let text = text.into(); self.documents.insert((sid, uri.clone()), text.clone()); - // T M11.8 / Session 8 — mark cached LSP-derived render - // families stale so semantic frontends suppress byte ranges - // anchored to pre-edit text until the server refreshes them. - // Diagnostics clear on `publishDiagnostics`, semantic tokens - // on `textDocument/semanticTokens`, and inlay hints on - // `textDocument/inlayHint`. - self.diag_store - .lock() - .expect("diag store mutex poisoned") - .mark_stale(uri.clone()); - self.semantic_token_store - .lock() - .expect("semantic token store mutex poisoned") - .mark_stale(uri.clone()); - self.inlay_hint_store - .lock() - .expect("inlay hint store mutex poisoned") - .mark_stale(uri.clone()); + self.mark_document_stale(&uri); let params = json!({ "textDocument": { "uri": uri, @@ -3074,6 +3057,32 @@ impl LspManager { self.send_notification(sid, "textDocument/didChange", params) } + /// T M11.8 / Session 8 — mark cached LSP-derived render families + /// for `uri` stale so frontends suppress byte ranges anchored to + /// pre-edit text until the server refreshes them. Diagnostics + /// clear on `publishDiagnostics`, semantic tokens on + /// `textDocument/semanticTokens`, and inlay hints on + /// `textDocument/inlayHint`. + /// + /// Factored out of [`Self::did_change_full`] so the Lua glue can + /// mark staleness at *edit* time even while the (full-document, + /// O(file)) didChange notification itself is debounced — per-edit + /// staleness is what keeps stale-position artifacts off screen. + pub fn mark_document_stale(&self, uri: &str) { + self.diag_store + .lock() + .expect("diag store mutex poisoned") + .mark_stale(uri.to_owned()); + self.semantic_token_store + .lock() + .expect("semantic token store mutex poisoned") + .mark_stale(uri.to_owned()); + self.inlay_hint_store + .lock() + .expect("inlay hint store mutex poisoned") + .mark_stale(uri.to_owned()); + } + /// Send `workspace/didChangeWatchedFiles` to `sid`. `changes` is /// the already-shaped `FileEvent[]` array (`[{ uri, type }]`, /// type 1=created / 2=changed / 3=deleted) the Lua file-watch diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index 334ef3f..33afe52 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -7261,6 +7261,23 @@ pub fn install_lsp( )?; } + { + // Mark `uri`'s cached LSP render families (diagnostics, + // semantic tokens, inlay hints) stale without sending + // anything. The didChange-debounce glue in + // `builtin/runtime/lsp.lua` calls this per edit so stale + // suppression stays keystroke-accurate while the O(file) + // full-document notification is coalesced. + let m = manager.clone(); + lsp_mod.set( + "_mark_document_stale", + lua.create_function(move |_, uri: String| { + m.borrow().mark_document_stale(&uri); + Ok(()) + })?, + )?; + } + { let m = manager.clone(); lsp_mod.set( @@ -11428,6 +11445,22 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result })?, )?; } + { + // Milliseconds on a process-local monotonic clock. Only + // differences are meaningful (the epoch is the first call). + // Exists for Lua-side debounce/throttle logic — notably the + // LSP didChange coalescing in `builtin/runtime/lsp.lua` — + // which needs wall-clock-independent elapsed time; `os.clock` + // is CPU time and `os.time` is second-granular. + editor.set( + "monotonic_ms", + lua.create_function(|_, ()| { + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + let epoch = *EPOCH.get_or_init(std::time::Instant::now); + Ok(i64::try_from(epoch.elapsed().as_millis()).unwrap_or(i64::MAX)) + })?, + )?; + } { let cc = core.clone(); editor.set( diff --git a/src/process.rs b/src/process.rs index f990711..0b88352 100644 --- a/src/process.rs +++ b/src/process.rs @@ -51,7 +51,7 @@ use std::collections::HashMap; use std::io::{Read, Write}; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -427,7 +427,7 @@ struct ManagedProcess { /// when the generation ends. struct RuntimeHandles { child: ChildHandle, - stdin: Option>, + stdin: Option, pid: u32, /// Reader-thread join handles, drained by `Drop` of /// [`RuntimeHandles`] so a generation's worker threads don't @@ -444,6 +444,102 @@ struct RuntimeHandles { cancel: Arc, } +/// Byte budget for stdin data queued but not yet written, per +/// generation. A child this far behind on reading its own stdin is +/// effectively not consuming it; erroring beats unbounded queue +/// growth, and callers already treat `write_stdin` errors as +/// process failure. Generous so it never triggers for a merely-busy +/// child (LSP full-document didChange on a large file is ~MB-scale). +const STDIN_QUEUE_MAX_BYTES: usize = 64 * 1024 * 1024; + +/// Queued stdin writer: a dedicated thread owns the child's stdin +/// handle and drains a channel of byte chunks. This decouples +/// callers — the editor main thread, notably the LSP manager's +/// full-document `didChange` notifications — from pipe +/// backpressure: a child that stops reading (kernel pipe buffers +/// are ~64 KiB) stalls this queue, not the editor frame loop. +/// +/// Closing: dropping the sender (`close_stdin` / generation end) +/// lets the thread drain whatever is queued, then drop the handle — +/// the child sees EOF *after* the queued bytes, preserving the +/// flush-then-EOF shutdown contract MCP relies on. The thread is +/// detached rather than joined: joining at drop could block forever +/// on a wedged pipe, and generation teardown (SIGTERM/SIGKILL) +/// breaks the pipe and ends the thread shortly after anyway. +struct StdinWriter { + tx: Sender>, + /// Bytes accepted by [`Self::write`] but not yet written by the + /// thread. Backpressure signal for the queue budget. + queued_bytes: Arc, + /// First write error observed by the writer thread. Writes are + /// asynchronous, so the failure surfaces on the *next* `write` + /// call instead of the one that hit it. + error: Arc>>, +} + +impl StdinWriter { + fn spawn(mut sink: Box) -> Self { + let (tx, rx) = channel::unbounded::>(); + let queued_bytes = Arc::new(AtomicUsize::new(0)); + let error = Arc::new(Mutex::new(None)); + let thread_queued = Arc::clone(&queued_bytes); + let thread_error = Arc::clone(&error); + std::thread::Builder::new() + .name("pmacs stdin writer".into()) + .spawn(move || { + while let Ok(bytes) = rx.recv() { + let result = sink.write_all(&bytes).and_then(|()| sink.flush()); + thread_queued.fetch_sub(bytes.len(), Ordering::Relaxed); + if let Err(e) = result { + *thread_error.lock().expect("stdin writer error mutex poisoned") = + Some(e.to_string()); + return; + } + } + // Channel closed: all queued chunks written. `sink` + // drops here, closing the pipe — the child sees EOF. + }) + .expect("spawn stdin writer thread"); + Self { + tx, + queued_bytes, + error, + } + } + + fn write(&self, bytes: &[u8]) -> Result<(), String> { + if let Some(e) = self + .error + .lock() + .expect("stdin writer error mutex poisoned") + .as_ref() + { + return Err(format!("write_stdin: {e}")); + } + let queued = self.queued_bytes.load(Ordering::Relaxed); + if queued.saturating_add(bytes.len()) > STDIN_QUEUE_MAX_BYTES { + return Err(format!( + "write_stdin: child is not draining stdin ({queued} bytes already queued)" + )); + } + self.queued_bytes.fetch_add(bytes.len(), Ordering::Relaxed); + self.tx.send(bytes.to_vec()).map_err(|_| { + // Thread exited after a write error; report the stored + // cause when we have it. + self.queued_bytes.fetch_sub(bytes.len(), Ordering::Relaxed); + let stored = self + .error + .lock() + .expect("stdin writer error mutex poisoned") + .clone(); + stored.map_or_else( + || "write_stdin: writer thread stopped".to_owned(), + |e| format!("write_stdin: {e}"), + ) + }) + } +} + impl Drop for RuntimeHandles { fn drop(&mut self) { // Wake any reader thread blocked in a bounded `send` --- @@ -723,12 +819,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 +846,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 +865,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 +1225,7 @@ fn build_pipes_runtime(spec: &ProcessSpec, _id: ProcessId) -> Result); + .map(|s| StdinWriter::spawn(Box::new(s) as Box)); let stdout = child.stdout.take(); let stderr = child.stderr.take(); let (byte_tx, byte_rx) = channel::bounded::(BYTE_CHUNK_CHANNEL_CAP); @@ -1238,7 +1335,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 +1703,82 @@ mod tests { ); } + #[test] + fn write_stdin_queues_without_blocking_when_child_never_reads() { + let mut sup = ProcessSupervisor::new(); + // The child never reads its stdin, so the kernel pipe buffer + // (~64 KiB) fills almost immediately. The pre-writer-thread + // implementation blocked the caller in `write_all` here — + // which in the editor was the main thread, wedging the frame + // loop whenever an LSP server fell behind on its stdin. + let mut spec = ProcessSpec::new("stdin-ignorer", "/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + let id = sup.spawn(spec).expect("spawn"); + let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { + evs.iter() + .any(|e| matches!(e.kind, ProcessEventKind::Started { .. })) + }); + let payload = vec![b'x'; 1024 * 1024]; // 16x the pipe buffer + let start = Instant::now(); + sup.write_stdin(id, &payload).expect("queued write"); + assert!( + start.elapsed() < Duration::from_secs(2), + "write_stdin must queue, not block on pipe backpressure (took {:?})", + start.elapsed() + ); + sup.terminate(id).expect("terminate"); + let _ = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); + } + + #[test] + fn close_stdin_flushes_queued_bytes_before_eof() { + let mut sup = ProcessSupervisor::new(); + // `cat` echoes stdin and exits on EOF. Receiving the full + // payload back followed by a clean exit proves the writer + // thread drains its queue before dropping the pipe (the + // flush-then-EOF contract `close_stdin` documents). + let mut spec = ProcessSpec::new("cat-echo", "/bin/sh"); + spec.args = vec!["-c".into(), "cat".into()]; + let id = sup.spawn(spec).expect("spawn"); + let _ = drain_until(&mut sup, id, Duration::from_secs(2), |evs| { + evs.iter() + .any(|e| matches!(e.kind, ProcessEventKind::Started { .. })) + }); + let payload = vec![b'y'; 256 * 1024]; + sup.write_stdin(id, &payload).expect("queued write"); + sup.close_stdin(id).expect("close stdin"); + let evs = drain_until(&mut sup, id, Duration::from_secs(10), |evs| { + let echoed: usize = evs + .iter() + .filter_map(|e| match &e.kind { + ProcessEventKind::Stdout(b) => Some(b.len()), + _ => None, + }) + .sum(); + echoed >= 256 * 1024 + && evs + .iter() + .any(|e| matches!(e.kind, ProcessEventKind::Exited { .. })) + }); + let echoed: usize = evs + .iter() + .filter_map(|e| match &e.kind { + ProcessEventKind::Stdout(b) => Some(b.len()), + _ => None, + }) + .sum(); + assert_eq!( + echoed, + payload.len(), + "child must receive every queued byte before EOF" + ); + assert!( + evs.iter() + .any(|e| matches!(e.kind, ProcessEventKind::Exited { code: 0 })), + "EOF after drain must let the child exit cleanly" + ); + } + #[test] fn restart_on_crash_respawns_after_nonzero_exit() { let mut sup = ProcessSupervisor::new(); diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 675695e..b7cc9d9 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -5158,6 +5158,106 @@ fn m4_12_default_bundle_wires_commands_and_keymaps() { assert!(probe.get::("cmd_sig").unwrap()); } +/// Typing-perf: the default bundle coalesces full-document +/// `didChange` notifications instead of sending one per keystroke +/// (each send copies the whole buffer several times and writes +/// O(file) JSON to the server pipe). The after-edit hook only bumps +/// the version and records the buffer dirty; the notification ships +/// on the async tick after the quiet window, or synchronously when a +/// request path flushes via `pmacs.lsp._flush_did_changes`. Observed +/// by monkeypatching `pmacs.lsp.did_change` (the bundle resolves it +/// dynamically at flush time) and firing `buffer.after-edit` through +/// the public hook runner. +#[test] +fn m4_lua_bundle_debounces_did_change_per_keystroke() { + use pmacs::editor::EditorState; + + let mut s = EditorState::new(); + let fake = fake_lsp_path(); + let dir = tempfile::TempDir::new().unwrap(); + let file = dir.path().join("debounce.rs"); + std::fs::write(&file, "fn main() {}\n").unwrap(); + let file_disp = file.display(); + + // Point the rust config at the fake server, open the file (the + // after-load hook auto-attaches and sends didOpen v1), then + // instrument did_change. + s.lua_host + .lua() + .load(format!( + " + pmacs.lsp.config.rust = {{ command = '{fake}' }} + pmacs.buffer.find_or_open('{file_disp}') + _G.__sent_did_changes = {{}} + local real = pmacs.lsp.did_change + pmacs.lsp.did_change = function(sid, uri, version, text) + table.insert(_G.__sent_did_changes, {{ version = version, len = #text }}) + return real(sid, uri, version, text) + end + " + )) + .exec() + .expect("configure + open + instrument"); + + // Three "keystrokes" in a burst: nothing may ship inline. + s.lua_host + .lua() + .load("for _ = 1, 3 do pmacs.hook.run('buffer.after-edit') end") + .exec() + .expect("fire after-edit burst"); + let sent: i64 = s + .lua_host + .lua() + .load("return #_G.__sent_did_changes") + .eval() + .expect("count sends"); + assert_eq!(sent, 0, "didChange must not ship per keystroke"); + + // Request-path flush: exactly one coalesced notification carrying + // the latest version (didOpen was v1, three edits bump to v4 — + // skipped intermediate versions are legal, LSP only requires + // strictly increasing). + let (sent, version): (i64, i64) = s + .lua_host + .lua() + .load( + " + pmacs.lsp._flush_did_changes() + local n = #_G.__sent_did_changes + local v = n > 0 and _G.__sent_did_changes[n].version or -1 + return n, v + ", + ) + .eval() + .expect("flush + count"); + assert_eq!(sent, 1, "explicit flush ships exactly one coalesced didChange"); + assert_eq!(version, 4, "flush carries the latest version (v1 open + 3 edits)"); + + // Time-based flush: one more edit, then tick after the quiet + // window (75ms in the bundle) has elapsed. + s.lua_host + .lua() + .load("pmacs.hook.run('buffer.after-edit')") + .exec() + .expect("fire single after-edit"); + std::thread::sleep(Duration::from_millis(120)); + s.tick_async(); + let (sent, version): (i64, i64) = s + .lua_host + .lua() + .load( + " + local n = #_G.__sent_did_changes + local v = n > 0 and _G.__sent_did_changes[n].version or -1 + return n, v + ", + ) + .eval() + .expect("count after tick"); + assert_eq!(sent, 2, "quiet-window tick flushes the pending didChange"); + assert_eq!(version, 5, "tick flush carries the post-edit version"); +} + /// Defensive: the auto-attach hook ignores buffers that don't have a /// language config, doesn't crash on `*scratch*`, and pcall-wraps the /// spawn so a missing server binary in the user's PATH doesn't poison From bd728c470563eaede92e4c1d9dcaafe60337b03b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 10:50:11 -0400 Subject: [PATCH 14/19] =?UTF-8?q?CUA=20region=20semantics=20=E2=80=94=20sh?= =?UTF-8?q?ift=20selection,=20region-aware=20delete=20+=20type-over?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S-/S-/S- (+C-S word/paragraph variants) extend a selection; the TUI grid paints it reverse-video; double-click selects the word at point. - Backspace / Delete consume the active region (delete_region first, falling back to single-codepoint semantics). - Typing replaces the region: buffer.self-insert / newline / tab delete_region before inserting. pmacs-gpu cooperates by round-tripping keys while an own-window selection is active, so the region-aware commands run instead of a raw optimistic op. - tests/cua_region_acceptance.rs drives the real dispatch path: select -> BS/DEL/char/Enter, plus the no-region fallbacks. Co-Authored-By: Claude Fable 5 --- builtin/commands/default.lua | 43 ++++-- builtin/keymaps/default.lua | 5 +- src/editor.rs | 241 ++++++++++++++++++++++++++++++++- src/editor_core.rs | 32 +++++ tests/cua_region_acceptance.rs | 139 +++++++++++++++++++ 5 files changed, 444 insertions(+), 16 deletions(-) create mode 100644 tests/cua_region_acceptance.rs diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 68872c3..f210f39 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -40,10 +40,20 @@ cmd { name = "cursor.paragraph-down", -- Buffer editing ------------------------------------------------------------- -cmd { name = "buffer.delete-backward", description = "Delete the codepoint before the cursor.", - fn = function() ed.backspace() end } -cmd { name = "buffer.delete-forward", description = "Delete the codepoint at the cursor.", - fn = function() ed.delete_forward() end } +-- CUA region semantics: with an active selection, Backspace / Delete +-- consume the region (cursor lands at its start, selection clears). +-- `delete_region` returns false when no region is active, so the +-- single-codepoint behavior is untouched outside selections. +cmd { name = "buffer.delete-backward", + description = "Delete the active region, or the codepoint before the cursor.", + fn = function() + if not ed.delete_region() then ed.backspace() end + end } +cmd { name = "buffer.delete-forward", + description = "Delete the active region, or the codepoint at the cursor.", + fn = function() + if not ed.delete_region() then ed.delete_forward() end + end } cmd { name = "buffer.delete-word-backward", description = "Delete from the cursor back to the start of the previous word.", fn = function() ed.delete_word_backward() end } @@ -79,18 +89,31 @@ cmd { name = "cursor.select-word-left", cmd { name = "cursor.select-word-right", description = "Extend selection by one word right.", fn = function() ensure_anchor(); ed.move_word_right() end } +cmd { name = "cursor.select-paragraph-up", + description = "Extend selection to the previous paragraph break.", + fn = function() ensure_anchor(); ed.move_paragraph_up() end } +cmd { name = "cursor.select-paragraph-down", + description = "Extend selection to the next paragraph break.", + fn = function() ensure_anchor(); ed.move_paragraph_down() end } cmd { name = "cursor.select-line-start", description = "Extend selection to start of line.", fn = function() ensure_anchor(); ed.move_line_start() end } cmd { name = "cursor.select-line-end", description = "Extend selection to end of line.", fn = function() ensure_anchor(); ed.move_line_end() end } -cmd { name = "buffer.newline", description = "Insert a newline at the cursor.", - fn = function() ed.insert_char(10) end } -cmd { name = "buffer.tab", description = "Insert a tab at the cursor.", - fn = function() ed.insert_char(9) end } -cmd { name = "buffer.self-insert", description = "Insert the codepoint argument at the cursor.", - fn = function(codepoint) ed.insert_char(codepoint) end } +-- CUA type-over: inserting with an active selection replaces it +-- (`delete_region` is a no-op without one). The pmacs-gpu frontend +-- relies on this: its optimistic-insert path detects an own-window +-- selection and round-trips the key so these commands run. +cmd { name = "buffer.newline", + description = "Insert a newline at the cursor, replacing the active region.", + fn = function() ed.delete_region(); ed.insert_char(10) end } +cmd { name = "buffer.tab", + description = "Insert a tab at the cursor, replacing the active region.", + fn = function() ed.delete_region(); ed.insert_char(9) end } +cmd { name = "buffer.self-insert", + description = "Insert the codepoint argument at the cursor, replacing the active region.", + fn = function(codepoint) ed.delete_region(); ed.insert_char(codepoint) end } -- History -------------------------------------------------------------------- diff --git a/builtin/keymaps/default.lua b/builtin/keymaps/default.lua index e2b01ad..16adab5 100644 --- a/builtin/keymaps/default.lua +++ b/builtin/keymaps/default.lua @@ -73,7 +73,8 @@ bind("M-d", "buffer.delete-word-forward") -- CUA-style Shift+motion selection. Each Shift+arrow extends a -- selection from the cursor (anchoring at the current position if no -- region is yet active). Ctrl+Shift+Left/Right extend by whole words; --- Shift+Home/End extend to line edges. Plain motion (without Shift) +-- Ctrl+Shift+Up/Down extend by paragraphs; Shift+Home/End extend to +-- line edges. Plain motion (without Shift) -- is unchanged --- it preserves any existing selection rather than -- dropping it (Emacs-flavored default; users who want strict-CUA -- "drop-on-plain-motion" can rebind their motion commands). @@ -85,6 +86,8 @@ bind("S-", "cursor.select-line-start") bind("S-", "cursor.select-line-end") bind("C-S-", "cursor.select-word-left") bind("C-S-", "cursor.select-word-right") +bind("C-S-", "cursor.select-paragraph-up") +bind("C-S-", "cursor.select-paragraph-down") -- Undo / redo ---------------------------------------------------------------- -- diff --git a/src/editor.rs b/src/editor.rs index 6b20371..d5999bf 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -16,7 +16,7 @@ use std::cell::RefCell; use std::io; use std::path::PathBuf; use std::rc::Rc; -use std::time::Duration; +use std::time::{Duration, Instant}; use crossterm::event::{KeyCode, KeyModifiers}; @@ -97,8 +97,21 @@ pub struct EditorState { /// Snippet store (T M4.11). Co-owned with the snippet /// provider closure inside [`Self::completion_registry`]. pub snippets: crate::completion_framework::SharedSnippetRegistry, + /// Last left-button down event, used to synthesize terminal double + /// clicks from crossterm's plain Down/Up mouse event stream. + mouse_click: Option, } +#[derive(Copy, Clone)] +struct MouseClickState { + frontend_id: FrontendId, + window_id: WindowId, + cell: CellCoord, + at: Instant, +} + +const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500); + impl EditorState { /// Construct a fresh editor for an unnamed scratch buffer. /// @@ -322,6 +335,7 @@ impl EditorState { project_indexer, completion_registry, snippets, + mouse_click: None, } } @@ -662,6 +676,8 @@ impl EditorState { /// positions the buffer cursor at the corresponding rope /// position. Starts an empty selection at that position so /// a drag continues the region from there. + /// * A second `Down(Left)` in the same cell within the double-click + /// threshold selects the word at the click position. /// * `Drag(Left)` updates the cursor as the mouse moves; the /// anchor stays put, so the region grows. /// * `Up(Left)` ends a drag. If anchor and cursor coincide @@ -699,14 +715,28 @@ impl EditorState { match ev.kind { MouseEventKind::Down(MouseButton::Left) => { if local_row >= inner_rows { + self.mouse_click = None; return; // Mode-line click: reserved. } + let click_cell = CellCoord::new(cell_row, cell_col); + let is_double_click = self.is_double_click(frontend_id, win_id, click_cell); self.activate_and_position(win_id, local_row, local_col); - let mut core = self.core.borrow_mut(); - let pos = core.cursor(); - core.begin_selection(pos); + if is_double_click && self.core.borrow_mut().select_word_at_cursor() { + self.mouse_click = None; + } else { + let mut core = self.core.borrow_mut(); + let pos = core.cursor(); + core.begin_selection(pos); + self.mouse_click = Some(MouseClickState { + frontend_id, + window_id: win_id, + cell: click_cell, + at: Instant::now(), + }); + } } MouseEventKind::Drag(MouseButton::Left) => { + self.mouse_click = None; if local_row >= inner_rows { return; } @@ -721,15 +751,34 @@ impl EditorState { } } MouseEventKind::ScrollUp => { + self.mouse_click = None; self.scroll_window(win_id, -SCROLL_LINES); } MouseEventKind::ScrollDown => { + self.mouse_click = None; self.scroll_window(win_id, SCROLL_LINES); } - _ => {} + _ => { + self.mouse_click = None; + } } } + fn is_double_click( + &self, + frontend_id: FrontendId, + window_id: WindowId, + cell: CellCoord, + ) -> bool { + let Some(prev) = self.mouse_click else { + return false; + }; + prev.frontend_id == frontend_id + && prev.window_id == window_id + && prev.cell == cell + && prev.at.elapsed() <= DOUBLE_CLICK_MAX_DELAY + } + /// Make `win_id` the active window and place its cursor at the /// buffer position corresponding to `(local_row, local_col)`, /// where the coordinates are relative to the window's viewport @@ -1110,6 +1159,7 @@ pub fn paint_frame( for overlay in &mut window.overlays { overlay.render(buf, viewport, grid); } + paint_local_selection(grid, buf, window, &rect, inner_rows); // Mode line for this window. Painted last so the line // itself is always visible regardless of overlay activity. let coord = window @@ -1199,6 +1249,62 @@ fn inner_rows(rect: &crate::window::Rect) -> u32 { rect.size.rows.saturating_sub(1) } +fn paint_local_selection( + grid: &mut crate::cell::CellGrid<'_>, + buf: &crate::buffer::Buffer, + window: &crate::window::Window, + rect: &crate::window::Rect, + inner_rows: u32, +) { + let Some((sel_start, sel_end)) = window.region() else { + return; + }; + if inner_rows == 0 || rect.size.cols == 0 || sel_start >= sel_end { + return; + } + + let first_row = window.view_top; + let last_row = first_row.saturating_add(inner_rows as usize); + for display_row in first_row..last_row { + let Some(line_start) = window.text_view.line_offset(display_row) else { + continue; + }; + let Some(line_len) = window.text_view.line_len(buf, display_row) else { + continue; + }; + let line_end = line_start.saturating_add(line_len); + let paint_start = sel_start.max(line_start); + let paint_end = sel_end.min(line_end); + if paint_start >= paint_end { + continue; + } + + let Some(start_coord) = window.text_view.pos_to_display(buf, paint_start) else { + continue; + }; + let Some(end_coord) = window.text_view.pos_to_display(buf, paint_end) else { + continue; + }; + if start_coord.row as usize != display_row || end_coord.row as usize != display_row { + continue; + } + + let row_offset = display_row.saturating_sub(first_row) as u32; + let start_col = start_coord.col.min(rect.size.cols); + let end_col = end_coord.col.min(rect.size.cols); + if start_col >= end_col { + continue; + } + for col in start_col..end_col { + let cell = grid.at(CellCoord::new( + rect.origin.row + row_offset, + rect.origin.col + col, + )); + cell.style.reverse = true; + } + } +} + #[allow( clippy::too_many_arguments, reason = "the mode line packs eight unrelated facts; bundling them into a struct just adds ceremony" @@ -4055,6 +4161,94 @@ mod tests { assert!(core.active_region().is_none()); } + #[test] + fn mouse_drag_selection_paints_in_tui_grid() { + use crossterm::event::{MouseButton, MouseEventKind}; + let mut s = fresh_with(b"hello world\n"); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), 0, 0), + term_size_24x80(), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), 0, 5), + term_size_24x80(), + ); + + let (cells, _, _) = render_to_grid(&s, 24, 80); + for col in 0..5 { + let style = cells[col as usize].style; + assert!(style.reverse, "selected col {col} was not reverse video"); + } + assert!( + !cells[5].style.reverse, + "unselected cell after mouse selection was reverse video" + ); + } + + #[test] + fn mouse_double_click_selects_word_and_paints_in_tui_grid() { + use crossterm::event::{MouseButton, MouseEventKind}; + let mut s = fresh_with(b"hello world\n"); + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), 0, 7), + term_size_24x80(), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Up(MouseButton::Left), 0, 7), + term_size_24x80(), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), 0, 7), + term_size_24x80(), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Up(MouseButton::Left), 0, 7), + term_size_24x80(), + ); + + assert_eq!(s.core.borrow().cursor(), 11); + assert_eq!(s.core.borrow().active_region(), Some((6, 11))); + + let (cells, _, _) = render_to_grid(&s, 24, 80); + assert!(!cells[5].style.reverse, "selection leaked into separator"); + for col in 6..11 { + assert!( + cells[col as usize].style.reverse, + "double-click selected word missing col {col}" + ); + } + assert!(!cells[11].style.reverse, "selection leaked past word"); + } + + #[test] + fn mouse_double_click_on_separator_leaves_no_region() { + use crossterm::event::{MouseButton, MouseEventKind}; + let mut s = fresh_with(b"hello world\n"); + + for _ in 0..2 { + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), 0, 5), + term_size_24x80(), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Up(MouseButton::Left), 0, 5), + term_size_24x80(), + ); + } + + assert_eq!(s.core.borrow().cursor(), 5); + assert!(s.core.borrow().active_region().is_none()); + } + /// Acceptance bullet 3: mouse events are coalesced at frame /// boundaries — many drag events between renders all apply, and /// the cursor ends up at the last position. @@ -4317,6 +4511,43 @@ mod tests { assert_eq!(s.core.borrow().cursor(), 14); } + #[test] + fn shift_arrow_extends_selection_and_paints_in_tui_grid() { + let mut s = fresh_with(b"abcdef\n"); + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT)); + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT)); + + assert_eq!(s.core.borrow().cursor(), 2); + assert_eq!(s.core.borrow().active_region(), Some((0, 2))); + + let (cells, stride, _) = render_to_grid(&s, 24, 80); + assert!(cells[0].style.reverse, "selection did not paint col 0"); + assert!(cells[1].style.reverse, "selection did not paint col 1"); + assert!(!cells[2].style.reverse, "selection leaked into col 2"); + assert_eq!(glyph_at(&cells, stride, 0, 0), 'a'); + assert_eq!(glyph_at(&cells, stride, 0, 1), 'b'); + } + + #[test] + fn ctrl_shift_arrow_extends_selection_by_words_and_paragraphs() { + let mut s = fresh_with(b"alpha beta\n\nsecond\n"); + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Right, KeyModifiers::CONTROL | KeyModifiers::SHIFT), + ); + assert_eq!(s.core.borrow().cursor(), 5); + assert_eq!(s.core.borrow().active_region(), Some((0, 5))); + + s.core.borrow_mut().active_window_mut().cursor = 0; + s.core.borrow_mut().clear_selection(); + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT), + ); + assert_eq!(s.core.borrow().cursor(), 11); + assert_eq!(s.core.borrow().active_region(), Some((0, 11))); + } + #[test] fn page_down_advances_cursor_and_view_top() { let mut content = Vec::new(); diff --git a/src/editor_core.rs b/src/editor_core.rs index af54432..24798ca 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -757,6 +757,28 @@ impl EditorCore { aw.goal_col = None; } + /// Select the word at the active cursor. Returns `false` when the + /// cursor is not on a word character. + pub fn select_word_at_cursor(&mut self) -> bool { + let id = self.active_buffer_id(); + let cursor = self.active_window().cursor; + let range = { + let reg = self.registry.borrow(); + let Ok(buffer) = reg.get(id) else { + return false; + }; + word_range_at(buffer, cursor) + }; + let Some((start, end)) = range else { + return false; + }; + let aw = self.active_window_mut(); + aw.selection = Some(crate::window::Selection { anchor: start }); + aw.cursor = end; + aw.goal_col = None; + true + } + /// Move the cursor forward to the next paragraph break. /// /// A paragraph break is a blank line (empty or whitespace-only). @@ -1383,6 +1405,16 @@ fn forward_word(buf: &Buffer, mut pos: Position) -> Position { pos } +fn word_range_at(buf: &Buffer, pos: Position) -> Option<(Position, Position)> { + let (ch, _) = char_at(buf, pos)?; + if !is_word_char(ch) { + return None; + } + let start = backward_word(buf, pos); + let end = forward_word(buf, pos); + (start < end).then_some((start, end)) +} + /// True iff `line` is empty or contains only ASCII whitespace. /// Used by paragraph motion: a blank line is a paragraph break. fn line_is_blank(buf: &Buffer, view: &TextView, line: usize) -> bool { diff --git a/tests/cua_region_acceptance.rs b/tests/cua_region_acceptance.rs new file mode 100644 index 0000000..717e402 --- /dev/null +++ b/tests/cua_region_acceptance.rs @@ -0,0 +1,139 @@ +//! 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); +} + +#[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); +} From 21671cc5907ec0e6c6a530b685145586af0d4fba Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 11:25:21 -0400 Subject: [PATCH 15/19] =?UTF-8?q?optimistic=20Backspace/Delete=20=E2=80=94?= =?UTF-8?q?=20single-codepoint=20deletes=20apply=20locally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last round-tripping editing keys. Same latency profile Enter had: mid-burst they deferred behind unconfirmed inserts and everything typed after them flushed in a delayed lump. Daemon: single-delete CRDT hot path in apply_remote_crdt_op. The deletion's start byte converts through the post-import doc (the prefix is untouched); the end byte comes from walking the still pre-import rope over the deleted codepoint count (reads at most 4 bytes per codepoint, not the file). Compound updates keep the materialize+diff fallback. pmacs-gpu: - optimistic_crdt_delete mirrors the insert path; the shared gates (optimistic_edit_eligible) and tail (finish_optimistic_edit) are factored out. optimistic_delete_range predicts exactly one codepoint — matching buffer.delete-backward/-forward's no-region behavior — and declines on buffer edges, modifier variants (C-BS word delete), or a mid-codepoint cursor. Region deletes keep round-tripping into delete_region via the selection gate. - Cursor-floor semantics tightened for non-monotonic predictions: only the exact predicted byte (or another buffer) confirms; plus a 500ms timeout escape hatch — an unconfirmed floor (op dropped by validation, peer racing the window cursor) now releases instead of wedging deferred keys forever, falling back to round-trip input until the next CursorByte resynchronizes. - Unconfirmed-edit journal rebasing generalized from pure inserts to delete-shaped entries (old_end translates independently, clamped). Co-Authored-By: Claude Fable 5 --- pmacs-gpu/src/main.rs | 311 ++++++++++++++++++++++++++++++++++-------- src/buffer.rs | 121 +++++++++++++++- 2 files changed, 374 insertions(+), 58 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 1da86fa..ff5ba31 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -366,6 +366,13 @@ struct State { /// the floor is acknowledged would make its legitimate cursor /// result indistinguishable from an older in-flight frame. deferred_round_trip_keys: Vec<(ProtocolKey, Modifiers)>, + /// When the current `optimistic_cursor_floor` was armed. If the + /// daemon never confirms the prediction (op dropped by + /// validation, a peer racing our window cursor), an unbounded + /// floor would wedge deferred round-trip keys forever; after + /// [`FLOOR_CONFIRM_TIMEOUT`] the floor releases, `cursor_fresh` + /// drops, and the next `CursorByte` resynchronizes. + optimistic_floor_set_at: Option, /// Optimistic local edits not yet known to be reflected in /// incoming producer frames. Each entry pairs the version scalar /// of this replica's doc *after* the edit applied (computed by @@ -471,11 +478,11 @@ impl ApplicationHandler for App { && should_forward_key(pkey, pmods) && let Some(client) = self.attach_client.as_ref() { - if let Some(op) = self - .state - .as_mut() - .and_then(|state| state.optimistic_crdt_insert(pkey, pmods)) - { + if let Some(op) = self.state.as_mut().and_then(|state| { + state + .optimistic_crdt_insert(pkey, pmods) + .or_else(|| state.optimistic_crdt_delete(pkey, pmods)) + }) { if debug_input() { eprintln!( "pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B", @@ -568,6 +575,7 @@ impl ApplicationHandler for App { { eprintln!("pmacs-gpu: send Viewport failed: {e}"); } + state.release_timed_out_floor(); let ready_keys = state.take_ready_round_trip_keys(); if let Some(client) = self.attach_client.as_ref() { for (key, mods) in ready_keys { @@ -608,6 +616,45 @@ struct CrdtOpSend { viewport: Option, } +/// How long an unconfirmed optimistic-cursor prediction may gate +/// `CursorByte` acceptance and defer round-trip keys before the +/// escape hatch releases it. Generous against a busy daemon tick; +/// tiny against a human noticing wedged keys. +const FLOOR_CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); + +/// Byte range an optimistic Backspace/Delete removes at `cursor`, or +/// `None` when it can't be predicted locally: buffer edge (the +/// daemon's behavior is a no-op there anyway), a modifier variant +/// (C-BS word-delete and friends are separate bindings), or a stale +/// mid-codepoint cursor. The range is exactly one codepoint, matching +/// `buffer.delete-backward` / `buffer.delete-forward`'s no-region +/// behavior; region deletes are excluded upstream by the selection +/// gate (they round-trip into `delete_region`). +fn optimistic_delete_range( + text: &str, + cursor: usize, + key: ProtocolKey, + mods: Modifiers, +) -> Option<(usize, usize)> { + if !mods.is_empty() { + return None; + } + if cursor > text.len() || !text.is_char_boundary(cursor) { + return None; + } + match key { + ProtocolKey::Backspace => { + let (start, _) = text[..cursor].char_indices().next_back()?; + Some((start, cursor)) + } + ProtocolKey::Delete => { + let ch = text[cursor..].chars().next()?; + Some((cursor, cursor + ch.len_utf8())) + } + _ => None, + } +} + /// The literal text `key` inserts when handled optimistically, or /// `None` for keys that must round-trip through the daemon. /// @@ -813,6 +860,7 @@ impl State { cursor_fresh: false, optimistic_cursor_floor: None, deferred_round_trip_keys: Vec::new(), + optimistic_floor_set_at: None, unconfirmed_edits: Vec::new(), } } @@ -826,19 +874,22 @@ impl State { } } - fn optimistic_crdt_insert(&mut self, key: ProtocolKey, mods: Modifiers) -> Option { + /// Shared eligibility gates for the optimistic edit paths + /// (insert + delete). `None` ⇒ the key must round-trip: + /// - dispatcher busy (minibuffer/prefix flows own the keys), or + /// the cursor isn't authoritative; + /// - CUA region semantics: an own-window selection means typing + /// replaces and Backspace/Delete consume the region — those + /// semantics live in the daemon's region-aware commands, which + /// a raw `CrdtOp` bypasses. (Our own selection arrives as a + /// `Selection` decoration; peer selections live in + /// `peer_presences` and don't gate.) + /// - bookkeeping: no frontend id / cursor / matching buffer / + /// replica doc, or the peer id can't be set. + fn optimistic_edit_eligible(&self) -> Option<(OwnCursor, u64)> { if !self.dispatch_idle || !self.cursor_fresh { return None; } - // CUA type-over: with an active selection, typing replaces the - // region. Those semantics live in the daemon's region-aware - // insert commands (`buffer.self-insert` / `newline` / `tab`), - // which a raw CrdtOp insert would bypass — so an own-window - // selection sends the key round-trip instead. Our own - // selection arrives as a `Selection` decoration (peer - // selections live in `peer_presences` and don't gate). The - // daemon's replace clears the region, the next Decorations - // frame clears the wash, and typing resumes optimistically. if self .current_decorations .iter() @@ -846,17 +897,11 @@ impl State { { return None; } - let mut chbuf = [0u8; 4]; - let insert = optimistic_insert_text(key, mods, &mut chbuf)?; let frontend_id = self.local_frontend_id?; let own = self.own_cursor?; if self.current_buffer_id != Some(own.buffer_id) { return None; } - let cursor = usize::try_from(own.byte).ok()?; - if cursor > self.current_text.len() || !self.current_text.is_char_boundary(cursor) { - return None; - } let doc = self.loro_doc.as_ref()?; let peer_id = frontend_id.0; if doc.peer_id() != peer_id @@ -865,7 +910,18 @@ impl State { eprintln!("pmacs-gpu: failed to set optimistic Loro peer id: {e:?}"); return None; } + Some((own, peer_id)) + } + fn optimistic_crdt_insert(&mut self, key: ProtocolKey, mods: Modifiers) -> Option { + let mut chbuf = [0u8; 4]; + let insert = optimistic_insert_text(key, mods, &mut chbuf)?; + let (own, peer_id) = self.optimistic_edit_eligible()?; + let cursor = usize::try_from(own.byte).ok()?; + if cursor > self.current_text.len() || !self.current_text.is_char_boundary(cursor) { + return None; + } + let doc = self.loro_doc.as_ref()?; let delta_batches = self.loro_text_delta_batches.clone()?; clear_loro_text_delta_batches(&delta_batches); let before = doc.oplog_vv(); @@ -877,19 +933,79 @@ impl State { .export(ExportMode::updates(&before)) .expect("export local optimistic Loro update"); let drained = drain_loro_text_delta_batches(&delta_batches); + let predicted = OwnCursor { + buffer_id: own.buffer_id, + byte: own.byte.saturating_add(insert.len() as u64), + }; + Some(self.finish_optimistic_edit(&drained, predicted, peer_id, bytes)) + } + + /// Optimistic single-codepoint Backspace / Delete. Mirrors the + /// insert path: the daemon's `buffer.delete-backward/-forward` + /// no-region behavior is exactly "delete one codepoint", so the + /// local application cannot diverge; region deletes are excluded + /// by the selection gate (they round-trip into `delete_region`), + /// and modified variants (C-BS word delete, …) round-trip via + /// `optimistic_delete_range` returning `None`. The daemon applies + /// the op through its single-delete CRDT hot path. + fn optimistic_crdt_delete(&mut self, key: ProtocolKey, mods: Modifiers) -> Option { + if !matches!(key, ProtocolKey::Backspace | ProtocolKey::Delete) { + return None; + } + let (own, peer_id) = self.optimistic_edit_eligible()?; + let cursor = usize::try_from(own.byte).ok()?; + let (start, end) = optimistic_delete_range(&self.current_text, cursor, key, mods)?; + let doc = self.loro_doc.as_ref()?; + let delta_batches = self.loro_text_delta_batches.clone()?; + clear_loro_text_delta_batches(&delta_batches); + let before = doc.oplog_vv(); + if let Err(e) = doc + .get_text(LORO_TEXT_CONTAINER) + .delete_utf8(start, end - start) + { + eprintln!("pmacs-gpu: optimistic delete failed: {e:?}"); + return None; + } + let bytes = doc + .export(ExportMode::updates(&before)) + .expect("export local optimistic Loro update"); + let drained = drain_loro_text_delta_batches(&delta_batches); + let predicted = OwnCursor { + buffer_id: own.buffer_id, + byte: start as u64, + }; + Some(self.finish_optimistic_edit(&drained, predicted, peer_id, bytes)) + } + + /// Common tail of the optimistic edit paths: patch the local text + /// from the drained Loro deltas (journaling them for + /// incoming-frame translation), predict the cursor + arm the + /// confirmation floor, follow the caret, and package the wire op. + fn finish_optimistic_edit( + &mut self, + drained: &[Vec], + predicted: OwnCursor, + peer_id: u64, + bytes: Vec, + ) -> CrdtOpSend { if drained.is_empty() { - let text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); - self.set_text(&text); + let text = self + .loro_doc + .as_ref() + .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); + if let Some(text) = text { + self.set_text(&text); + } // Cache rebuilt wholesale — there are no translated // anchors left for frame translation to protect. self.unconfirmed_edits.clear(); } else { - match self.apply_loro_text_delta_batches(&drained) { + match self.apply_loro_text_delta_batches(drained) { Ok(edits) => { // Journal this keystroke so producer frames the // daemon computed before integrating it can be // translated on arrival (see `unconfirmed_edits`). - // The scalar is read *after* the local insert, so + // The scalar is read *after* the local edit, so // any frame stamped at or beyond it includes us. let scalar = self.loro_doc.as_ref().map_or(0, loro_version_scalar); self.unconfirmed_edits @@ -911,27 +1027,25 @@ impl State { } } } - let predicted = OwnCursor { - buffer_id: own.buffer_id, - byte: own.byte.saturating_add(insert.len() as u64), - }; self.own_cursor = Some(predicted); self.optimistic_cursor_floor = Some(predicted); + self.optimistic_floor_set_at = Some(std::time::Instant::now()); // Follow the caret NOW rather than when the daemon's // `CursorByte` confirms — an optimistic Enter on the bottom - // visible line moves the caret to a line below the slice, and - // waiting a round trip to scroll reads as a hitch. + // visible line (or a Backspace pulling the caret above the + // top) moves it outside the slice, and waiting a round trip + // to scroll reads as a hitch. let viewport = if self.scroll_to_cursor() { self.reshape(); - self.viewport_send_if_changed(own.buffer_id) + self.viewport_send_if_changed(predicted.buffer_id) } else { None }; - Some(CrdtOpSend { - buffer_id: own.buffer_id, + CrdtOpSend { + buffer_id: predicted.buffer_id, op: CrdtOp { peer_id, bytes }, viewport, - }) + } } fn mark_cursor_stale_after_round_trip(&mut self) { @@ -971,6 +1085,27 @@ impl State { .retain(|(scalar, _)| *scalar > generation); } + fn optimistic_floor_timed_out(&self) -> bool { + self.optimistic_floor_set_at + .is_some_and(|armed| armed.elapsed() >= FLOOR_CONFIRM_TIMEOUT) + } + + /// Escape hatch: release a floor the daemon never confirmed so + /// deferred round-trip keys can't wedge forever. Dropping + /// `cursor_fresh` falls the GPU back to round-trip mode until the + /// next `CursorByte` resynchronizes the cursor. + fn release_timed_out_floor(&mut self) { + if self.optimistic_cursor_floor.is_some() && self.optimistic_floor_timed_out() { + eprintln!( + "pmacs-gpu: optimistic cursor unconfirmed after {FLOOR_CONFIRM_TIMEOUT:?}; \ + falling back to round-trip input" + ); + self.optimistic_cursor_floor = None; + self.optimistic_floor_set_at = None; + self.cursor_fresh = false; + } + } + fn defer_round_trip_key_if_needed(&mut self, key: ProtocolKey, mods: Modifiers) -> bool { if self.optimistic_cursor_floor.is_none() && self.deferred_round_trip_keys.is_empty() { return false; @@ -1087,6 +1222,7 @@ impl State { self.own_cursor = None; self.cursor_fresh = false; self.optimistic_cursor_floor = None; + self.optimistic_floor_set_at = None; self.deferred_round_trip_keys.clear(); self.unconfirmed_edits.clear(); // New buffer ⇒ back to the top, and force a viewport @@ -1167,16 +1303,20 @@ impl State { Ok(edits) => { // A daemon-originated edit shifts the text // under any still-unconfirmed optimistic - // inserts. Rebase the journal's anchors so + // edits. Rebase the journal's anchors so // frames that include this edit (but not - // ours) translate correctly. Journal - // entries are pure inserts (the optimistic - // path only inserts), so anchor == start. + // ours) translate correctly. Entries are + // inserts (start == old_end) or + // single-codepoint deletes; both rebase by + // position translation, clamped so a range + // can't invert. for incoming in &edits { for (_, pending) in &mut self.unconfirmed_edits { pending.start = translate_byte_position(pending.start, *incoming); - pending.old_end = pending.start; + pending.old_end = + translate_byte_position(pending.old_end, *incoming) + .max(pending.start); } } } @@ -1334,24 +1474,28 @@ impl State { self.current_buffer_id == Some(buffer_id) ); } - if let Some(floor) = self.optimistic_cursor_floor - && floor.buffer_id == buffer_id - && byte_pos < floor.byte - { - if debug_input() { - eprintln!( - "pmacs-gpu cursor: ignored stale optimistic rewind \ - buf={buffer_id:?} byte={byte_pos} floor={}", - floor.byte - ); + if let Some(floor) = self.optimistic_cursor_floor { + // With deletes in the optimistic set the predicted + // cursor is no longer monotonic, so only the EXACT + // predicted byte (or a cursor for another buffer) + // confirms; any other value is an in-flight frame + // from before our unconfirmed edits. The timeout + // hatch accepts daemon truth if confirmation never + // comes (op dropped, peer raced our cursor). + let confirmed = floor.buffer_id != buffer_id || byte_pos == floor.byte; + if confirmed || self.optimistic_floor_timed_out() { + self.optimistic_cursor_floor = None; + self.optimistic_floor_set_at = None; + } else { + if debug_input() { + eprintln!( + "pmacs-gpu cursor: ignored stale in-flight position \ + buf={buffer_id:?} byte={byte_pos} predicted={}", + floor.byte + ); + } + return None; } - return None; - } - if self - .optimistic_cursor_floor - .is_some_and(|floor| floor.buffer_id != buffer_id || byte_pos >= floor.byte) - { - self.optimistic_cursor_floor = None; } self.own_cursor = Some(OwnCursor { buffer_id, @@ -3695,6 +3839,59 @@ mod tests { ); } + #[test] + fn optimistic_delete_range_covers_single_codepoints_only() { + let none = Modifiers::NONE; + let text = "aé😀b"; + + // Backspace deletes the codepoint before the cursor, whatever + // its width: 'é' is 2 bytes, '😀' is 4. + assert_eq!( + optimistic_delete_range(text, 3, ProtocolKey::Backspace, none), + Some((1, 3)), + "backspace before the cursor crosses the full 'é'" + ); + assert_eq!( + optimistic_delete_range(text, 7, ProtocolKey::Backspace, none), + Some((3, 7)), + "backspace crosses the full '😀'" + ); + // Delete removes the codepoint at the cursor. + assert_eq!( + optimistic_delete_range(text, 1, ProtocolKey::Delete, none), + Some((1, 3)) + ); + assert_eq!( + optimistic_delete_range(text, 7, ProtocolKey::Delete, none), + Some((7, 8)) + ); + + // Buffer edges: nothing to delete ⇒ round-trip (daemon no-op). + assert_eq!( + optimistic_delete_range(text, 0, ProtocolKey::Backspace, none), + None + ); + assert_eq!( + optimistic_delete_range(text, text.len(), ProtocolKey::Delete, none), + None + ); + // Mid-codepoint (stale) cursor ⇒ round-trip, never a panic. + assert_eq!( + optimistic_delete_range(text, 2, ProtocolKey::Backspace, none), + None + ); + // Modified variants are separate bindings (C-BS word delete). + assert_eq!( + optimistic_delete_range(text, 3, ProtocolKey::Backspace, Modifiers::CTRL), + None + ); + // Non-delete keys are not this helper's business. + assert_eq!( + optimistic_delete_range(text, 3, ProtocolKey::Char('x'), none), + None + ); + } + #[test] fn incoming_frames_translate_through_unconfirmed_edits() { // A frame computed at daemon generation G arrives while one diff --git a/src/buffer.rs b/src/buffer.rs index d45740a..e01a683 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -765,7 +765,25 @@ impl Buffer { return result.map(Some); } - // Conservative fallback for deletes, compound updates, and + // 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(); @@ -1490,6 +1508,72 @@ fn single_remote_text_insert(deltas: &[Vec]) -> Option<(usize, found } +/// Recognize the hot-path projection delta produced by one remote +/// deletion: an optional leading `Retain` followed by exactly one +/// `Delete`, nothing else. Returns `(unicode_start, deleted_chars)` +/// in Unicode scalar units. +#[cfg(feature = "crdt")] +fn single_remote_text_delete(deltas: &[Vec]) -> Option<(usize, usize)> { + let [delta] = deltas else { + return None; + }; + let mut cursor = 0usize; + let mut found = None; + for op in delta { + match op { + loro::TextDelta::Retain { retain, .. } => { + cursor = cursor.checked_add(*retain)?; + } + loro::TextDelta::Insert { insert, .. } if insert.is_empty() => {} + loro::TextDelta::Insert { .. } => return None, + loro::TextDelta::Delete { delete } if *delete == 0 => {} + loro::TextDelta::Delete { delete } => { + if found.is_some() { + return None; + } + found = Some((cursor, *delete)); + } + } + } + found +} + +/// Byte offset just past `chars` codepoints starting at `byte_start` +/// in `rope`. Reads at most `chars * 4` bytes (one UTF-8 max-width +/// each), so a Backspace-sized walk touches a handful of bytes, not +/// the file. `None` when the rope runs out (or a boundary is off) — +/// callers fall back to the materialize-and-diff path. +#[cfg(feature = "crdt")] +fn rope_byte_end_after_chars( + rope: &crate::rope::Rope, + byte_start: Position, + chars: usize, +) -> Option { + let len = rope.len(); + if byte_start > len || chars == 0 { + return None; + } + let take = (chars as Position).saturating_mul(4).min(len - byte_start); + let mut buf = vec![0u8; take as usize]; + rope.slice(byte_start, byte_start + take, &mut buf); + let s = match std::str::from_utf8(&buf) { + Ok(s) => s, + // The 4*chars window can cut a trailing codepoint that we + // don't need anyway; keep the valid prefix. + Err(e) => std::str::from_utf8(&buf[..e.valid_up_to()]).ok()?, + }; + let mut remaining = chars; + let mut offset = 0usize; + for ch in s.chars() { + if remaining == 0 { + break; + } + offset += ch.len_utf8(); + remaining -= 1; + } + (remaining == 0).then(|| byte_start + offset as Position) +} + /// T M10.4: derive a fine-grained `(range, inserted_len)` Edit /// description for the change from `old_rope` to `new_rope` via /// longest-common-prefix + longest-common-suffix trim. @@ -2952,6 +3036,41 @@ mod tests { 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 From 2339a10b037f37d8baaa2da823803290fb626a07 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 11:48:23 -0400 Subject: [PATCH 16/19] pmacs-gpu: forward chorded deletion keys (C-BS word delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backspace/Delete with modifiers were withheld by the chord filter, so C-BS / C-DEL / M-BS — word-level deletes in the default keymap — silently did nothing in the GPU while C- word motion worked. Deletion keys now forward with their modifiers exactly like motion keys; an unbound chord is a harmless no-op at the daemon keymap, and chorded deletes never apply optimistically (optimistic_delete_range requires empty modifiers), so they always round-trip into their bound commands. Acceptance test drives C-BS / C-DEL through the real dispatch path. Co-Authored-By: Claude Fable 5 --- pmacs-gpu/src/main.rs | 34 +++++++++++++++++++++++----------- tests/cua_region_acceptance.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index ff5ba31..44f57e5 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -2649,26 +2649,31 @@ fn is_motion_key(key: ProtocolKey) -> bool { } /// Whether to forward a translated key to the daemon (session B2). -/// Motion keys go through with any modifiers. Plain text-editing keys -/// (`Char` / `Backspace` / `Enter` / `Delete` / `Tab`) go through only -/// *without* a Ctrl/Alt/Meta chord modifier: a bare key edits text, -/// but a chord drives commands and minibuffer flows the GUI can't -/// render or interact with yet (deferred to a later session). Shift is -/// not a chord modifier — `Shift`+a already arrives as `Char('A')`. +/// Motion keys go through with any modifiers (C- is word +/// motion). Deletion keys do too: C-BS / C-DEL / M-BS are word-level +/// deletes in the default keymap — the same editing-command family as +/// chorded motion, and an unbound chord is a harmless no-op at the +/// daemon keymap. (Chorded deletes never apply optimistically: +/// `optimistic_delete_range` requires empty modifiers, so they always +/// round-trip into their bound commands.) The remaining text keys +/// (`Char` / `Enter` / `Tab`) go through only *without* a +/// Ctrl/Alt/Meta chord modifier: a bare key edits text, but those +/// chords drive commands and minibuffer flows the GUI can't render or +/// interact with yet (deferred to a later session). Shift is not a +/// chord modifier — `Shift`+a already arrives as `Char('A')`. fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { if is_motion_key(key) { return true; } + if matches!(key, ProtocolKey::Backspace | ProtocolKey::Delete) { + return true; + } if !is_plain_text_modifiers(mods) { return false; } matches!( key, - ProtocolKey::Char(_) - | ProtocolKey::Backspace - | ProtocolKey::Enter - | ProtocolKey::Delete - | ProtocolKey::Tab + ProtocolKey::Char(_) | ProtocolKey::Enter | ProtocolKey::Tab ) } @@ -3600,6 +3605,13 @@ mod tests { assert!(should_forward_key(ProtocolKey::Left, ctrl)); assert!(should_forward_key(ProtocolKey::Down, shift)); assert!(should_forward_key(ProtocolKey::PageUp, none)); + + // Deletion keys forward regardless of modifiers too — C-BS / + // C-DEL / M-BS are word-level deletes in the default keymap, + // the same editing-command family as chorded motion. + assert!(should_forward_key(ProtocolKey::Backspace, ctrl)); + assert!(should_forward_key(ProtocolKey::Delete, ctrl)); + assert!(should_forward_key(ProtocolKey::Backspace, Modifiers::ALT)); } #[test] diff --git a/tests/cua_region_acceptance.rs b/tests/cua_region_acceptance.rs index 717e402..26eec00 100644 --- a/tests/cua_region_acceptance.rs +++ b/tests/cua_region_acceptance.rs @@ -74,6 +74,32 @@ fn backspace_deletes_the_shift_selected_region() { 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(); From e8b5b94a4da1d44c34e38565da56b37a4d8661be Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 11:53:50 -0400 Subject: [PATCH 17/19] style: cargo fmt over the optimistic-editing arc Co-Authored-By: Claude Fable 5 --- pmacs-gpu/src/main.rs | 5 ++++- src/process.rs | 5 +++-- tests/cua_region_acceptance.rs | 15 ++++++++++++--- tests/m4_acceptance.rs | 10 ++++++++-- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 44f57e5..923a542 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -925,7 +925,10 @@ impl State { let delta_batches = self.loro_text_delta_batches.clone()?; clear_loro_text_delta_batches(&delta_batches); let before = doc.oplog_vv(); - if let Err(e) = doc.get_text(LORO_TEXT_CONTAINER).insert_utf8(cursor, insert) { + if let Err(e) = doc + .get_text(LORO_TEXT_CONTAINER) + .insert_utf8(cursor, insert) + { eprintln!("pmacs-gpu: optimistic insert failed: {e:?}"); return None; } diff --git a/src/process.rs b/src/process.rs index 0b88352..307d56c 100644 --- a/src/process.rs +++ b/src/process.rs @@ -491,8 +491,9 @@ impl StdinWriter { 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()); + *thread_error + .lock() + .expect("stdin writer error mutex poisoned") = Some(e.to_string()); return; } } diff --git a/tests/cua_region_acceptance.rs b/tests/cua_region_acceptance.rs index 26eec00..e7e6676 100644 --- a/tests/cua_region_acceptance.rs +++ b/tests/cua_region_acceptance.rs @@ -60,7 +60,10 @@ fn backspace_deletes_the_shift_selected_region() { 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)); + 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"); @@ -68,7 +71,10 @@ fn backspace_deletes_the_shift_selected_region() { 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)); + 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); @@ -87,7 +93,10 @@ fn ctrl_backspace_deletes_the_previous_word() { key(KeyCode::Backspace, KeyModifiers::CONTROL), ); let (text, _, cursor) = probe(&s); - assert_eq!(text, "alpha ", "C-BS deletes back through the previous word"); + 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)); diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index b7cc9d9..9e1dfd9 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -5230,8 +5230,14 @@ fn m4_lua_bundle_debounces_did_change_per_keystroke() { ) .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)"); + 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. From 15a764d87a92a0abdb9d297465faf228572a04e1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 12:03:44 -0400 Subject: [PATCH 18/19] test: widen m9_2 cancel-race harvest margin (macOS CI flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fake server sleeps 250ms before responding; a 350ms total budget left 100ms for two pipe transits + thread scheduling, which flaked on loaded macOS runners — and the queued-stdin-writer hop added by the typing-perf arc narrows it further. The contract under test is cancel-after-queue-before-manager-tick, which any long-enough-for-the-response wait preserves. Co-Authored-By: Claude Fable 5 --- tests/m9_2_acceptance.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/m9_2_acceptance.rs b/tests/m9_2_acceptance.rs index 2e98b16..b509f0d 100644 --- a/tests/m9_2_acceptance.rs +++ b/tests/m9_2_acceptance.rs @@ -536,10 +536,15 @@ fn m9_2_cancelled_sibling_wins_over_queued_response() { .read_resource(sid, "file:///race") .expect("read c"); - // Let the fake server finish its delayed response, then harvest - // the supervisor event queue without giving McpManager a chance - // to process it yet. - std::thread::sleep(Duration::from_millis(350)); + // Let the fake server finish its delayed response (it sleeps + // 250ms), then harvest the supervisor event queue without giving + // McpManager a chance to process it yet. The margin over the + // fake's delay must absorb two pipe transits plus the request's + // queued-stdin-writer hop under CI load (a 100ms margin flaked on + // macOS runners); a generous wait does not weaken the contract — + // the race under test is cancel-AFTER-queue-BEFORE-manager-tick, + // which holds for any wait long enough for the response to land. + std::thread::sleep(Duration::from_millis(1000)); sup.borrow_mut().tick(); // Cancel only b after the response is queued but before From dd1d65479965d4d4f2b89ee351ddd490aa5ad5df Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 12:07:08 -0400 Subject: [PATCH 19/19] style: from_secs(1) for the m9_2 harvest wait (clippy duration units) Co-Authored-By: Claude Fable 5 --- tests/m9_2_acceptance.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/m9_2_acceptance.rs b/tests/m9_2_acceptance.rs index b509f0d..06338c9 100644 --- a/tests/m9_2_acceptance.rs +++ b/tests/m9_2_acceptance.rs @@ -544,7 +544,7 @@ fn m9_2_cancelled_sibling_wins_over_queued_response() { // 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_millis(1000)); + std::thread::sleep(Duration::from_secs(1)); sup.borrow_mut().tick(); // Cancel only b after the response is queued but before