//! pmacs-gpu — GPU/GUI frontend for pmacs. //! //! Two run modes: //! //! - **Hello-world** (no `--attach` argument; session 2 default). //! Opens a window and renders "hello, pmacs" in the bundled //! `JetBrains` Mono. Used to confirm the wgpu/winit/glyphon stack //! without depending on a daemon. //! - **Attach** (`--attach `; session 3+). Connects //! to a running pmacs daemon, negotiates `semantic_render + //! crdt_replica`, imports the daemon's `BufferSnapshot` into a //! local loro replica, sends a `Viewport` back to request scoped //! styling, and consumes the `StyleSpans` stream — rendering the //! rope with per-span colors via cosmic-text's `set_rich_text`. //! Live `CrdtOp` updates apply to the doc; subsequent `StyleSpans` //! frames re-style. //! //! See `docs/pmacs-gpu-design.md` for the arc framing. Phase A's //! adversarial-verification framing applies from session 4 forward; //! findings classified per rule (iii) at surface-time. //! //! The bundled font is `JetBrains` Mono Regular, distributed under //! the SIL Open Font License 1.1 (see `fonts/OFL.txt`). mod attach; use std::collections::HashMap; use std::path::PathBuf; 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, CrdtOp, Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, InstanceSignal, Key as ProtocolKey, MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StyleSegment, StyleSpan, cell::{Color as CellColor, Style as CellStyle}, }; use wgpu::MultisampleState; use winit::application::ApplicationHandler; use winit::event::{ElementState, WindowEvent}; use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::keyboard::{Key, NamedKey}; use winit::window::{Window, WindowId}; use crate::attach::{AttachClient, AttachEvent}; /// Bundled font (SIL Open Font License 1.1 — see `fonts/OFL.txt`). const JETBRAINS_MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono-Regular.ttf"); /// Initial window size in logical pixels. const INITIAL_WIDTH: u32 = 800; const INITIAL_HEIGHT: u32 = 200; /// Color the surface clears to before text renders. const BG: wgpu::Color = wgpu::Color { r: 0.05, g: 0.05, b: 0.07, a: 1.0, }; const TEXT_LEFT: f32 = 16.0; const TEXT_TOP: f32 = 16.0; /// Caret bar width in px, and its color (bright, near-opaque — drawn /// over the text so it reads as the active insertion point). Session /// B1. const CARET_WIDTH: f32 = 2.0; const CARET_COLOR: [f32; 4] = [0.90, 0.90, 0.96, 0.90]; /// Extra source lines shaped beyond the visible window so a 1-line /// scroll doesn't always re-slice and the bottom partial line renders /// (Q#S3). Kept small — overscan is wasted shaping. const SCROLL_OVERSCAN: usize = 2; const TEXT_RIGHT_GAP: f32 = 10.0; const MINIMAP_WIDTH: f32 = 48.0; const MINIMAP_RIGHT: f32 = 12.0; const MINIMAP_TOP: f32 = 12.0; const MINIMAP_BOTTOM: f32 = 12.0; const MINIMAP_MIN_SURFACE_WIDTH: u32 = 180; const MINIMAP_MIN_THUMB_HEIGHT: f32 = 18.0; const MINIMAP_H_PAD: f32 = 3.0; const MINIMAP_CODE_COLS: f32 = 100.0; const MINIMAP_MIN_STROKE_WIDTH: f32 = 1.5; const MINIMAP_MAX_LINE_STROKE_HEIGHT: f32 = 2.0; const CODE_LINE_HEIGHT: f32 = 22.0; const MINIMAP_BG: [f32; 4] = [0.075, 0.075, 0.105, 0.92]; const MINIMAP_DEFAULT_LINE: [f32; 4] = [0.23, 0.23, 0.29, 0.82]; const MINIMAP_THUMB_FILL: [f32; 4] = [0.82, 0.82, 0.92, 0.18]; const MINIMAP_THUMB_BORDER: [f32; 4] = [0.86, 0.86, 0.96, 0.7]; /// Q#M7 — dragging within this many pixels of the text area's top or /// bottom edge auto-scrolls toward the pointer. const EDGE_SCROLL_BAND: f32 = 24.0; /// Q#M7 — one line per tick while edge-scrolling. const EDGE_SCROLL_TICK: std::time::Duration = std::time::Duration::from_millis(35); /// Q#M6 (bet #2) — after a far jump (no shaped line reused), hold /// the redraw this long so the daemon's restyle usually lands before /// the first visible frame: the styled frame replaces the unstyled /// flash. Short enough to read as instantaneous when styling never /// arrives (plain-text buffers). const JUMP_STYLE_HOLD: std::time::Duration = std::time::Duration::from_millis(25); /// Status band (Q#S2): one-line strip reserved at the surface /// bottom — buffer name + modified star on the left, diagnostics / /// cursor / scroll readout on the right. const STATUS_BAND_HEIGHT: f32 = 26.0; const STATUS_BAND_BG: [f32; 4] = [0.105, 0.105, 0.145, 1.0]; const STATUS_TEXT_PAD: f32 = 10.0; const STATUS_FONT_SIZE: f32 = 13.0; const STATUS_LINE_HEIGHT: f32 = 18.0; // Context menu popup (Q#CM1). One row per item/separator; width tracks // the widest label (estimated from a fixed per-char advance, which the // code font's monospacing makes good enough for hit-testing + the bg // quad to agree). const MENU_ROW_HEIGHT: f32 = 22.0; const MENU_FONT_SIZE: f32 = 14.0; const MENU_LINE_HEIGHT: f32 = 22.0; const MENU_PAD_X: f32 = 12.0; const MENU_CHAR_W: f32 = 8.4; const MENU_MIN_WIDTH: f32 = 140.0; const MENU_MAX_WIDTH: f32 = 380.0; const MENU_BG: [f32; 4] = [0.16, 0.16, 0.20, 0.98]; const MENU_SELECTED_BG: [f32; 4] = [0.20, 0.40, 0.66, 1.0]; const MENU_SEPARATOR_BG: [f32; 4] = [0.30, 0.30, 0.36, 1.0]; // Minibuffer completion dropdown (Q#MB1). A vertical list anchored just // above the bottom band, best match at the top; reuses the menu popup's // colors. Width tracks the widest candidate (measured from the shaped // buffer). const MB_DROP_ROW_HEIGHT: f32 = 20.0; const MB_DROP_FONT_SIZE: f32 = 13.0; const MB_DROP_LINE_HEIGHT: f32 = 20.0; const MB_DROP_PAD_X: f32 = 10.0; const MB_DROP_MIN_WIDTH: f32 = 160.0; const MB_DROP_MAX_WIDTH: f32 = 480.0; const QUAD_SHADER: &str = r" struct VertexOut { @builtin(position) pos: vec4, @location(0) color: vec4, }; @vertex fn vs_main( @location(0) pos: vec2, @location(1) color: vec4, ) -> VertexOut { var out: VertexOut; out.pos = vec4(pos, 0.0, 1.0); out.color = color; return out; } @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { return in.color; } "; const QUAD_VERTEX_STRIDE: wgpu::BufferAddress = 24; const QUAD_VERTEX_ATTRS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4]; /// Diagnostic squiggle shader (Q#W1). The vertex carries, beyond NDC /// position and color, a `uv`: `uv.x` is the absolute screen-space /// pixel x (so the wave's phase is continuous across separately /// emitted glyph-run rects), `uv.y` is the signed pixel offset from /// the band's vertical centerline. The fragment draws an /// anti-aliased sine: alpha falls off with distance to the curve via /// `fwidth`/`smoothstep` (both core WGSL — no MSAA or feature flag). const SQUIGGLE_SHADER: &str = r" struct VertexOut { @builtin(position) pos: vec4, @location(0) uv: vec2, @location(1) color: vec4, }; @vertex fn vs_main( @location(0) pos: vec2, @location(1) uv: vec2, @location(2) color: vec4, ) -> VertexOut { var out: VertexOut; out.pos = vec4(pos, 0.0, 1.0); out.uv = uv; out.color = color; return out; } @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { let wavelength = 6.0; // px per full sine period let amplitude = 1.4; // px peak from centerline let thickness = 1.0; // px stroke half-width let two_pi = 6.2831853; let wave = amplitude * sin(in.uv.x * (two_pi / wavelength)); let dist = abs(in.uv.y - wave); let aa = fwidth(dist); let alpha = 1.0 - smoothstep(thickness - aa, thickness + aa, dist); return vec4(in.color.rgb, in.color.a * alpha); } "; const SQUIGGLE_VERTEX_STRIDE: wgpu::BufferAddress = 32; const SQUIGGLE_VERTEX_ATTRS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4]; /// Text the hello-world (and attach-pre-snapshot / attach-failed) /// modes render. Once the daemon's `BufferSnapshot` arrives the /// rendered text becomes the rope contents instead. const HELLO_TEXT: &str = "hello, pmacs"; /// Container id the daemon uses on its loro `LoroDoc` for the /// buffer's text. Must match `pmacs::crdt::CrdtState`'s container /// name (`"body"`). const LORO_TEXT_CONTAINER: &str = "body"; /// Custom events delivered to the winit event loop. The reader thread /// in `attach.rs` forwards each decoded `InstanceMessage` through the /// `EventLoopProxy` it was handed by `connect()`; the main /// thread dispatches them in `user_event` below. #[derive(Debug)] pub enum AppEvent { /// A message or disconnect notification from the attach reader /// thread. Attach(AttachEvent), } /// CLI mode derived from argv. #[derive(Debug, Clone)] enum Mode { /// `pmacs-gpu` (no args): inert hello-world. HelloWorld, /// `pmacs-gpu --attach `: connect + render the daemon's /// rope. Attach { socket: PathBuf }, } fn main() { env_logger::init(); let mode = parse_args(std::env::args().skip(1).collect()); let event_loop = EventLoop::::with_user_event() .build() .expect("create winit event loop"); let proxy = event_loop.create_proxy(); let mut app = App { mode, proxy: Some(proxy), state: None, attach_client: None, modifiers: winit::keyboard::ModifiersState::empty(), }; event_loop .run_app(&mut app) .expect("winit event loop run_app"); } /// Tiny argv parser. No `clap` because the surface is genuinely two /// shapes; full CLI parsing arrives when there's more to parse. The /// `for` ranges over a small set: at most one `--attach ` or /// `--help` arrives, plus any stray unrecognized flag. fn parse_args(args: Vec) -> Mode { let mut iter = args.into_iter(); let Some(first) = iter.next() else { return Mode::HelloWorld; }; match first.as_str() { "--attach" => { let socket = iter.next().unwrap_or_else(|| { eprintln!("pmacs-gpu: --attach requires a socket path"); std::process::exit(2); }); Mode::Attach { socket: PathBuf::from(socket), } } "--help" | "-h" => { eprintln!( "pmacs-gpu — GPU/GUI frontend for pmacs\n\nUSAGE:\n pmacs-gpu \ hello-world (renders \"hello, pmacs\")\n pmacs-gpu --attach \ connect to a daemon's Unix socket and render its rope\n" ); std::process::exit(0); } other => { eprintln!("pmacs-gpu: unrecognized argument: {other}"); std::process::exit(2); } } } /// Top-level application handler. `state` is `Option` because winit /// 0.30 builds the window in `resumed()`, not at `main()` start; /// `attach_client` is held so the write half of the Unix stream /// stays alive for as long as the window does. struct App { mode: Mode, /// The event-loop proxy is taken in `resumed()` and handed to the /// reader thread. `Option` only because it can't be cloned out of /// a non-Option in a borrow. proxy: Option>, state: Option, /// Held both for stream lifetime and for the main loop's /// `send_viewport` / `send_key` write-back path. attach_client: Option, /// Latest modifier state from winit (`ModifiersChanged`). winit /// delivers modifiers separately from key presses, so we track the /// current set and apply it when a key is sent (session B1). modifiers: winit::keyboard::ModifiersState, } type LoroTextDeltaBatches = Arc>>>; /// All resources owned by one running pmacs-gpu instance. #[allow( clippy::struct_excessive_bools, reason = "independent render/input state flags, not a config bitset" )] struct State { window: Arc, device: wgpu::Device, queue: wgpu::Queue, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, font_system: FontSystem, swash_cache: SwashCache, viewport: Viewport, atlas: TextAtlas, text_renderer: TextRenderer, quad_renderer: QuadRenderer, squiggle_renderer: SquiggleRenderer, buffer: Buffer, /// What the buffer is currently shaped to. Held so we can detect /// no-op updates and skip the re-shape. current_text: String, /// 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). current_buffer_id: Option, /// Sorted-by-`range.start` styling spans for `current_buffer_id`. /// Replaced wholesale on `StyleSpans { full: true, .. }`; merged /// per the M11.4 dirty-segment rule on `full: false` (segments' /// ranges authoritatively replace styling within them; spans /// straddling a dirty edge get clipped to outside the dirty /// range). current_spans: Vec, /// Sorted-by-`range.start` decorations for `current_buffer_id`. /// Same M11.4 dirty-merge semantics as `current_spans`: `Decorations /// { full: true, .. }` replaces; `full: false` clips/replaces per /// segment range. /// /// Composition with `current_spans` in `reshape`: a decoration's /// color override beats the span's `style.fg` for the bytes it /// covers (semantic signal — a diagnostic — outranks syntactic /// signal). Decoration kinds whose visual is a background /// (`Selection`, `SearchMatch`, `SearchMatchActive`, `CurrentLine`) /// are not rendered in session 5; see the session-5 design note /// for the deferred quad-pipeline finding. current_decorations: Vec, /// Inline virtual text for `current_buffer_id` (session 6). /// Producer-side Phase A currently emits LSP inlay hints as /// `AtOffset` text adornments only. The GUI stores the whole scoped /// set and projects it into the shaped rich text without inserting /// bytes into `current_text`; source byte ranges for style spans and /// decorations therefore remain source-relative. current_adornments: Vec, /// Whole-file per-line dominant styles for the minimap (session 7). /// The daemon emits this summary on first frame and after CRDT /// generation changes. We keep the latest summary until a newer one /// arrives, matching the ownership rule used by style spans, /// decorations, and inline adornments. current_summary: Option, /// Peer presence (session 9.3), keyed by source frontend id. Each /// entry is one *other* attached frontend's cursor + selection, /// delivered via `InstanceMessage::PresenceUpdate`. A read-only /// mirror has no cursor of its own (no input path), so its own /// `Selection` / `CurrentLine` decorations are inert; the editing /// peer's presence is what the user actually watches. The quad- /// background path renders `Selection` / `CurrentLine` washes from /// these entries rather than from `current_decorations`. Sender /// exclusion at the daemon means our own id never appears here. peer_presences: HashMap, /// This frontend's own cursor (session B1), from the daemon's /// `CursorByte`. pmacs-gpu sends `Key` events; the daemon moves the /// authoritative window cursor and reports it back here (Q#B3), so /// the caret follows whatever the daemon decided — including motion /// from commands this frontend never interprets locally. `None` /// until the first `CursorByte`. own_cursor: Option, /// Top visible *source line* (0-based). Scroll is line-based /// (Q#S1). `reshape` shapes only the lines from here through the /// visible window; `view_range` records the byte span actually fed /// to cosmic-text so caret/wash byte offsets can be rebased onto /// it. scroll_top: usize, /// Whole-file byte range `[vstart, vend)` of the slice the /// cosmic-text `buffer` currently holds (session S1). Everything /// the buffer renders is in slice coordinates (`file_byte - /// vstart`); this is the rebasing origin for the caret and the /// background washes. view_range: (u64, u64), /// Last `[vstart, vend)` declared to the daemon via a `Viewport` /// event. Re-declared only when it changes (scroll, edit that /// shifts visible bytes, buffer switch) so the producer scopes /// `StyleSpans` to what's on screen without per-frame churn (Q#S5). last_viewport_sent: Option<(u64, u64)>, /// Frontend id assigned by the daemon. Needed for locally-authored /// optimistic CRDT ops, whose Loro peer id must match the /// authenticated frontend id the daemon sees on the socket. local_frontend_id: Option, /// Daemon-side key dispatcher state. Plain printable chars are /// optimistically applied only while this is true; when false, /// keys round-trip so minibuffer and prefix commands keep their /// daemon-owned semantics. dispatch_idle: bool, /// OS clipboard handle (Q#CM6), created lazily on first cut / copy / /// paste. `None` until first use or when the platform clipboard is /// unavailable (headless / unsupported compositor) --- clipboard ops /// then degrade to no-ops rather than crashing. clipboard: Option, /// Whether `own_cursor` is still an authoritative position for /// local optimistic insertion. Round-tripped keys can move the /// daemon cursor in ways the GPU does not predict, so they mark /// this false until the next `CursorByte`. cursor_fresh: bool, /// Furthest locally-predicted cursor after optimistic inserts that /// the daemon has not yet confirmed. `CursorByte` frames already /// in flight can arrive after local typing; accepting one below /// this floor would rewind subsequent optimistic inserts and /// scramble their order. optimistic_cursor_floor: Option, /// Round-trip keys typed while optimistic inserts are still /// awaiting confirmation. Sending a backward-moving key before /// the floor is acknowledged would make its legitimate cursor /// result indistinguishable from an older in-flight frame. deferred_round_trip_keys: Vec<(ProtocolKey, Modifiers)>, /// When the current `optimistic_cursor_floor` was armed. If the /// daemon never confirms the prediction (op dropped by /// validation, a peer racing our window cursor), an unbounded /// floor would wedge deferred round-trip keys forever; after /// [`FLOOR_CONFIRM_TIMEOUT`] the floor releases, `cursor_fresh` /// drops, and the next `CursorByte` resynchronizes. optimistic_floor_set_at: Option, /// Optimistic local edits not yet known to be reflected in /// incoming producer frames. Each entry pairs the version scalar /// of this replica's doc *after* the edit applied (computed by /// [`loro_version_scalar`], the same per-peer counter sum the /// daemon stamps into `StyleSpans` / `Decorations` `generation`) /// with the projection edit itself. On frame arrival, entries at /// or below the frame's generation are pruned and the frame's /// byte ranges are translated through the remainder — otherwise a /// frame computed before an in-flight keystroke repaints the /// viewport's colors a few bytes left of the text (the typing /// "color shimmer"). Cleared whenever the cache is rebuilt /// wholesale (snapshot / full-materialization fallback). /// /// Caveat (accepted): scalars from *divergent* replicas are not /// causally comparable, so a peer edit racing our unconfirmed /// ops can mis-prune by one frame; the next generation-keyed /// full resync self-corrects. unconfirmed_edits: Vec<(u64, TextProjectionEdit)>, /// Q#M2 — projected→source hit map for the currently shaped /// slice. Rebuilt by every `reshape` from the same chunks that /// feed glyphon; source offsets are slice-relative (pair with /// `view_range.0`). current_hit_runs: Vec, /// Line-start byte offsets of the *projected* text (cosmic-text /// reports hits as line index + byte-within-line). projected_line_starts: Vec, /// Last reported pointer position, in window pixels. pointer_pos: Option<(f64, f64)>, /// Primary button is held after a Down inside the text area. pointer_drag_active: bool, /// Hit byte of the last Pointer event sent — Drag coalescing: /// pixel-rate motion only ships when the hit byte changes. last_pointer_sent_byte: Option, /// `(when, byte, chain_count)` of the last primary Down, for /// frontend-side multi-click detection (same-hit within the /// interval): count 1 = single, 2 = the double already fired, /// so the next same-hit press is a triple (Q#M4). last_pointer_down: Option<(std::time::Instant, u64, u8)>, /// A press began inside the minimap band (Q#M6): subsequent /// `CursorMoved` scrubs the viewport instead of dragging a /// selection, until release. Never sends `Pointer` events — /// the viewport is frontend-owned. minimap_scrub_active: bool, /// Q#M7 — `Some(±1)` while a drag sits in the top/bottom edge /// band; `about_to_wait` ticks the viewport one line toward the /// pointer per [`EDGE_SCROLL_TICK`] and re-runs the drag /// hit-test (the mouse may be stationary — `CursorMoved` alone /// would stall the selection). edge_scroll_dir: Option, /// When the last edge-scroll tick fired. edge_scroll_last: Option, /// Q#M6 (bet #2) — a far jump rebuilt every visible line from /// spans that can't cover the new region; the redraw is held /// until restyle arrival (which clears this) or this deadline, /// whichever is first, so the unstyled frame usually never /// shows. `about_to_wait` enforces the deadline. styled_redraw_deadline: Option, /// Q#R2 — the per-line surgery path skips rebuilding the pointer /// hit map (clicks are rare next to keystrokes); this marks it /// stale so `hit_test_source_byte` rebuilds on demand from the /// same shared chunk function. hit_map_dirty: bool, /// Per-shaped-line chunk cache: `line_chunk_cache[i]` is the /// chunk set `buffer.lines[i]` was built from. Lets incoming /// frames re-shape ONLY lines whose styling actually changed, and /// lets scroll reuse retained lines wholesale. line_chunk_cache: Vec>, /// Absolute source-line index of `buffer.lines[0]`. shaped_top: usize, bg_vertex_buffer: ReusableVertexBuffer, squiggle_vertex_buffer: ReusableVertexBuffer, caret_vertex_buffer: ReusableVertexBuffer, minimap_vertex_buffer: ReusableVertexBuffer, /// Q#S2 — the status band's one-line text. Shaped only when the /// composed status string changes; rendered as a second /// `TextArea` in the same prepare pass as the main buffer. status_buffer: Buffer, /// The string `status_buffer` currently holds, for change /// detection. status_text: String, /// Q#S2 — the band's left side (buffer name + modified dot), /// its own buffer so it left-aligns independently of the /// right-aligned readout. status_left_buffer: Buffer, /// Change-detection twin of `status_text` for the left side. status_left_text: String, /// Q#S1 — the wire-authoritative status facts (protocol v8). status_facts: Option, /// Q#SR5 — the live incremental-search prompt (protocol v9), or /// `None` when no search is running. While `Some`, the status /// band's left side shows `I-search: (n/m)` in place of /// the buffer name; the matches highlight via `SearchMatch` /// decorations. search_prompt: Option, /// Q#MB1 — the live minibuffer (protocol v12), or `None` when /// closed. The prompt+input render in the bottom band; the /// candidates (when present) render as a dropdown above it. minibuffer: Option, /// Q#CM1 — the live context menu (protocol v11), or `None` when /// closed. The rows + highlight come from `MenuPrompt`; the popup /// draws at the pixel of the right-click. menu: Option, /// Pixel of the most recent right-click, remembered so the /// `MenuPrompt` that follows can anchor the popup there. menu_anchor_px: (f64, f64), /// Shaped label text for the open menu (Q#CM1), one line per row. menu_buffer: Buffer, /// Dedicated text renderer for the menu, so its glyphs draw in a /// layer *over* the buffer text + caret (a popup), not interleaved /// with them in the main text pass. menu_text_renderer: TextRenderer, /// Popup background / highlight / separator quads (Q#CM1). menu_bg_vertex_buffer: ReusableVertexBuffer, /// Shaped candidate text for the minibuffer dropdown (Q#MB1), one /// line per candidate. mb_buffer: Buffer, /// Dedicated text renderer for the minibuffer dropdown (its own /// layer over the buffer, like the menu's). mb_text_renderer: TextRenderer, /// Minibuffer dropdown background + selection quads (Q#MB1). mb_bg_vertex_buffer: ReusableVertexBuffer, /// Minimap vertex bytes cached by [`MinimapCacheKey`] — /// rebuilding rescanned every line shape per frame. minimap_cache: Option<(MinimapCacheKey, Vec)>, } /// The wire-authoritative status facts (Q#S1, protocol v8), /// mirrored from `InstanceMessage::StatusFacts`. #[derive(Clone, Debug, PartialEq, Eq)] struct StatusFactsLocal { buffer_id: BufferId, name: String, modified: bool, diag_errors: u32, diag_warnings: u32, } /// The live incremental-search prompt (Q#SR5/Q#RX6, protocol v10), /// mirrored from a `SearchPrompt` message whose `query` was `Some`. #[derive(Clone, Debug, PartialEq, Eq)] struct SearchPromptLocal { buffer_id: BufferId, query: String, active: Option, total: u32, regex: bool, invalid: bool, } /// The live minibuffer (Q#MB1, protocol v12), mirrored from a /// `MinibufferPrompt` whose `prompt` was `Some`. The prompt+input draw /// in the bottom band with a caret; `candidates` (a windowed slice) feed /// the dropdown. #[derive(Clone, Debug, PartialEq)] struct MinibufferLocal { prompt: String, input: String, cursor: u32, candidates: Vec, selected: Option, total: u32, } /// The live context menu (Q#CM1, protocol v11), mirrored from a /// `MenuPrompt` with non-empty rows. The popup draws at `anchor_px` /// (the right-click pixel, remembered locally — the daemon never sees /// pixels). #[derive(Clone, Debug, PartialEq)] struct MenuLocal { rows: Vec, active: Option, anchor_px: (f64, f64), } /// pmacs-gpu's own cursor position, mirrored from `CursorByte`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct OwnCursor { buffer_id: BufferId, byte: u64, } /// One peer frontend's cursor + selection in a buffer, from /// `InstanceMessage::PresenceUpdate`. Byte offsets are in the buffer's /// coordinate space; the renderer maps them to glyph rectangles via /// the local layout and clamps to text length, so a presence that /// briefly lags an edit can never index out. #[derive(Clone, Copy, Debug)] struct PeerPresence { buffer_id: BufferId, cursor: u64, selection: Option, } struct QuadRenderer { pipeline: wgpu::RenderPipeline, } #[derive(Clone, Debug)] struct FileStyleSummaryState { generation: u64, lines: Vec, } impl App { /// Ship a Pointer event if the daemon speaks protocol v5+ — the /// Q#M1 frontend-side gate (an older instance cannot decode the /// variant and would drop the connection). fn send_pointer(&self, buffer_id: BufferId, byte: u64, kind: PointerKind, mods: Modifiers) { let Some(client) = self.attach_client.as_ref() else { return; }; if client.server_protocol_version() < 5 { return; } // TripleDown is a v7 variant; a pre-v7 instance would // hard-error decoding it. Downgrade to a plain Down — the // exact behavior the third click had before v7 (the chain // restarting). let kind = if kind == PointerKind::TripleDown && client.server_protocol_version() < 7 { PointerKind::Down } else { kind }; // Context (right-click, Q#CM1) is a v11 variant; a pre-v11 // instance can't open a menu, so drop the gesture rather than // sending an undecodable variant. if kind == PointerKind::Context && client.server_protocol_version() < 11 { return; } if let Err(e) = client.send_pointer(buffer_id, byte, kind, mods) { eprintln!("pmacs-gpu: send_pointer failed: {e}"); } } /// Ship a [`pmacs_protocol::FrontendEvent::MenuPointer`] if the /// daemon speaks v11+ (Q#CM1). Navigates the open menu the daemon /// owns; pixels stay local, only the resolved row index crosses. fn send_menu_pointer(&self, index: Option, invoke: bool) { let Some(client) = self.attach_client.as_ref() else { return; }; if client.server_protocol_version() < 11 { return; } if let Err(e) = client.send_menu_pointer(index, invoke) { eprintln!("pmacs-gpu: send_menu_pointer failed: {e}"); } } } impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.state.is_some() { return; } let initial_text = match &self.mode { Mode::HelloWorld => HELLO_TEXT, Mode::Attach { .. } => "(connecting...)", }; self.state = Some(State::new(event_loop, initial_text)); // In attach mode, kick off the connection now that the event // loop is running and a proxy is available. Failure logs and // leaves the window showing its `(connecting...)` placeholder // — better UX than killing the window during dev. if let Mode::Attach { socket } = self.mode.clone() { 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) => { eprintln!("pmacs-gpu: attach failed: {e}"); if let Some(state) = self.state.as_mut() { state.set_text("(attach failed; see stderr)"); } } } } } #[allow(clippy::too_many_lines)] // linear per-event dispatch; splitting hides the input flow. fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { match event { WindowEvent::CloseRequested => event_loop.exit(), WindowEvent::ModifiersChanged(mods) => self.modifiers = mods.state(), WindowEvent::KeyboardInput { event: key, .. } => { if key.state != ElementState::Pressed { return; } // While the daemon is intercepting keystrokes — an active // incremental search (Q#SR5), or a minibuffer / pending // prefix — every key belongs to its handler, not the // buffer. The GUI round-trips them all and never // optimistic-applies (that would edit the document // mid-search). let intercept = self .state .as_ref() .is_some_and(State::daemon_intercepts_keys); // Escape cancels an active intercept (e.g. a running // search); otherwise it stays the local quit. if matches!(key.logical_key, Key::Named(NamedKey::Escape)) { if intercept { if let Some(client) = self.attach_client.as_ref() && let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE) { eprintln!("pmacs-gpu: send Escape (cancel) failed: {e}"); } } else { event_loop.exit(); } return; } let Some((pkey, mut pmods)) = translate_key(&key.logical_key, self.modifiers) else { return; }; // AltGr / international text (audit F-004). winit reports // the text a keypress produces; when a keypress yields // printable text *while* Ctrl/Alt is held — AltGr is // Ctrl+Alt on Windows and some layouts — it's text input, // not a command chord. Strip the Ctrl/Alt (keep Shift) so // it inserts (through the plain-text path, or the daemon's // SelfInsert while a prompt is open) instead of being // routed to the keymap. On platforms where AltGr isn't // Ctrl/Alt this is a no-op (the chord was already plain). if matches!(pkey, ProtocolKey::Char(_)) && is_layout_text(key.text.as_deref(), pmods) { pmods = if pmods.contains(Modifiers::SHIFT) { Modifiers::SHIFT } else { Modifiers::NONE }; } // Ctrl-V — OS paste (Q#CM6). Read the system clipboard // locally via arboard and ship it as a `Paste` event; the // daemon inserts it. Handled before binding `client` so // the `&mut self` clipboard read doesn't conflict with the // client borrow. Skipped while intercepting (the daemon's // active handler owns the key then). The daemon keymap's // C-y yanks the in-app slot instead. if !intercept && pkey == ProtocolKey::Char('v') && pmods == Modifiers::CTRL { let bytes = self.state.as_mut().and_then(State::read_os_clipboard); if let Some(bytes) = bytes && let Some(client) = self.attach_client.as_ref() && let Err(e) = client.send_paste(bytes) { eprintln!("pmacs-gpu: send_paste failed: {e}"); } return; } let Some(client) = self.attach_client.as_ref() else { return; }; // Intercept path: round-trip every key into the daemon's // active handler (search query / step / accept / cancel). if intercept { if let Some(state) = self.state.as_mut() { state.mark_cursor_stale_after_round_trip(); } if debug_input() { eprintln!("pmacs-gpu send_key (intercepted): {pkey:?} mods={pmods:?}"); } if let Err(e) = client.send_key(pkey, pmods) { eprintln!("pmacs-gpu: send_key (intercepted) failed: {e}"); } return; } // Idle: forward any command chord (Char/Enter/Tab with // Ctrl or Alt) to the daemon (Q#GC1). These drive the // keymap — `C-a`, `M-f`, `C-x C-s`, isearch/clipboard/M-x, // … — the same path the TUI forwards everything through. // The GUI no longer withholds them (the minibuffer / prompt // flows they open now render, Q#MB1). Once a forwarded // chord opens a prompt or enters a prefix, `dispatch_idle` // flips false and the intercept gate round-trips the rest — // no optimistic local flip, so a chord that changes no // daemon state can never wedge the gate. (Ctrl-V / OS paste // is handled locally above and never reaches here.) if is_command_chord(pkey, pmods) { if let Some(state) = self.state.as_mut() { state.mark_cursor_stale_after_round_trip(); } if let Err(e) = client.send_key(pkey, pmods) { eprintln!("pmacs-gpu: send_key (command chord) failed: {e}"); } return; } // Session B2 forwards cursor motion + plain text editing // (Char / Backspace / Enter / Delete / Tab). Command chords // are handled above; Meta/Super-only chords fall through // here and are withheld, leaving OS/WM shortcuts (Cmd-Q, // Cmd-C) to the platform. if !should_forward_key(pkey, pmods) { return; } if let Some(op) = self.state.as_mut().and_then(|state| { state .optimistic_crdt_insert(pkey, pmods) .or_else(|| state.optimistic_crdt_delete(pkey, pmods)) }) { if debug_input() { eprintln!( "pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B", op.buffer_id, op.op.bytes.len() ); } if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) { eprintln!("pmacs-gpu: send_crdt_op failed: {e}"); } // An optimistic Enter near the bottom edge can // scroll; re-declare the scoped viewport so // the producer styles the newly visible lines. if let Some(vp) = op.viewport && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) { eprintln!("pmacs-gpu: send Viewport failed: {e}"); } return; } if let Some(state) = self.state.as_mut() { if state.defer_round_trip_key_if_needed(pkey, pmods) { if debug_input() { eprintln!( "pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \ pending optimistic cursor" ); } return; } state.mark_cursor_stale_after_round_trip(); } if debug_input() { eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}"); } if let Err(e) = client.send_key(pkey, pmods) { eprintln!("pmacs-gpu: send_key failed: {e}"); } } WindowEvent::Resized(size) => { let vp = self .state .as_mut() .and_then(|state| state.resize(size.width.max(1), size.height.max(1))); if let Some(vp) = vp && let Some(client) = self.attach_client.as_ref() && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) { eprintln!("pmacs-gpu: resize send_viewport failed: {e}"); } } // Session M-2 — pointer input (docs/pmacs-gpu-mouse-framing.md). WindowEvent::CursorMoved { position, .. } => { let Some(state) = self.state.as_mut() else { return; }; state.pointer_pos = Some((position.x, position.y)); // Q#CM1 — while the menu is open, motion only moves the // highlight; send a hover when the item under the pointer // changes from the daemon's current active row. if state.menu.is_some() { let hit = state.menu_hit(position.x, position.y); let active = state.menu.as_ref().and_then(|m| m.active); if let Some((row, true)) = hit && active != Some(row) { self.send_menu_pointer(Some(row), false); } return; } if state.minimap_scrub_active { // Scrubbing (Q#M6): the press began on the // minimap; motion keeps jumping, even if the // pointer wanders out of the band. let vp = state.minimap_jump_to(position.y); if let Some(vp) = vp && let Some(client) = self.attach_client.as_ref() && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) { eprintln!("pmacs-gpu: minimap scrub send_viewport failed: {e}"); } return; } if !state.pointer_drag_active { return; } // Q#M7 — arm/disarm edge auto-scroll from the drag's // vertical position; `about_to_wait` runs the ticks. state.edge_scroll_dir = edge_scroll_direction(position.y as f32, state.config.height); // Drag coalescing (predicted finding #4): pixel-rate // motion only ships when the hit byte changes. let Some(byte) = state.hit_test_source_byte(position.x, position.y) else { return; }; if state.last_pointer_sent_byte == Some(byte) { return; } state.last_pointer_sent_byte = Some(byte); state.note_pointer_round_trip(); let buffer_id = state.current_buffer_id; let mods = translate_mods(self.modifiers); if let Some(buffer_id) = buffer_id { self.send_pointer(buffer_id, byte, PointerKind::Drag, mods); } } WindowEvent::MouseInput { state: button_state, button: winit::event::MouseButton::Left, .. } => { let Some(state) = self.state.as_mut() else { return; }; let Some((x, y)) = state.pointer_pos else { return; }; // Q#CM1 — while the menu is open the left button drives // it: a press invokes the row under the pointer (or // dismisses on a click outside); a release is swallowed. if state.menu.is_some() { if button_state == ElementState::Pressed { let action = match state.menu_hit(x, y) { Some((row, true)) => Some((Some(row), true)), Some((_, false)) => None, // separator — ignore None => Some((None, true)), // outside — dismiss }; if let Some((index, invoke)) = action { self.send_menu_pointer(index, invoke); } } return; } let mods = translate_mods(self.modifiers); match button_state { ElementState::Pressed => { if state.in_minimap_band(x, y) { // Q#M6 — consumed before text hit-testing; // never a Pointer event. state.minimap_scrub_active = true; let vp = state.minimap_jump_to(y); if let Some(vp) = vp && let Some(client) = self.attach_client.as_ref() && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) { eprintln!("pmacs-gpu: minimap jump send_viewport failed: {e}"); } return; } let Some(byte) = state.hit_test_source_byte(x, y) else { return; }; let kind = state.classify_pointer_down(byte, mods.contains(Modifiers::SHIFT)); state.pointer_drag_active = true; state.last_pointer_sent_byte = Some(byte); state.note_pointer_round_trip(); if let Some(buffer_id) = state.current_buffer_id { if debug_input() { eprintln!("pmacs-gpu pointer: {kind:?} byte={byte}"); } self.send_pointer(buffer_id, byte, kind, mods); } } ElementState::Released => { if state.minimap_scrub_active { state.minimap_scrub_active = false; return; } if !state.pointer_drag_active { return; } state.pointer_drag_active = false; state.edge_scroll_dir = None; state.edge_scroll_last = None; let byte = state .hit_test_source_byte(x, y) .or(state.last_pointer_sent_byte); let buffer_id = state.current_buffer_id; if let (Some(byte), Some(buffer_id)) = (byte, buffer_id) { self.send_pointer(buffer_id, byte, PointerKind::Up, mods); } } } } // Q#CM1 — right-click opens the context menu at the hit byte // (or dismisses an open one). The anchor pixel is remembered // so the popup the daemon sends back draws at the click. WindowEvent::MouseInput { state: ElementState::Pressed, button: winit::event::MouseButton::Right, .. } => { let Some(state) = self.state.as_mut() else { return; }; let Some((x, y)) = state.pointer_pos else { return; }; if state.menu.is_some() { self.send_menu_pointer(None, true); return; } let Some(byte) = state.hit_test_source_byte(x, y) else { return; }; state.menu_anchor_px = (x, y); let buffer_id = state.current_buffer_id; let mods = translate_mods(self.modifiers); if let Some(buffer_id) = buffer_id { self.send_pointer(buffer_id, byte, PointerKind::Context, mods); } } WindowEvent::MouseWheel { delta, .. } => { let Some(state) = self.state.as_mut() else { return; }; // Wheel scroll is local-only: the GPU owns the // viewport. Positive winit y = scroll up = smaller // scroll_top. let lines = match delta { winit::event::MouseScrollDelta::LineDelta(_, y) => { (-y * WHEEL_LINES_PER_TICK).round() as i64 } winit::event::MouseScrollDelta::PixelDelta(p) => { (-(p.y as f32) / CODE_LINE_HEIGHT).round() as i64 } }; if lines == 0 { return; } let vp = state.scroll_by_lines(lines); 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: wheel send_viewport failed: {e}"); } } WindowEvent::RedrawRequested => { if let Some(state) = self.state.as_mut() { state.render(); } } _ => {} } } /// The deadline pump. Two timed concerns share it, both armed /// rarely: /// /// * Q#M7 — the edge auto-scroll tick, while a drag sits in /// the top/bottom edge band. Each due tick scrolls one line /// toward the pointer and re-runs the drag hit-test at the /// *current* pointer position, so the selection keeps /// growing while the mouse is stationary past the edge. /// * Q#M6 (bet #2) — the post-jump styled-redraw hold: if the /// daemon's restyle hasn't landed by the deadline, draw the /// unstyled frame anyway (responsiveness floor). /// /// With neither armed the loop stays in plain `Wait`. fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { let Some(state) = self.state.as_mut() else { return; }; let now = std::time::Instant::now(); let mut next_wake: Option = None; // Q#M6 — held post-jump frame. if let Some(deadline) = state.styled_redraw_deadline { if now >= deadline { state.styled_redraw_deadline = None; state.window.request_redraw(); } else { next_wake = Some(deadline); } } // Q#M7 — edge auto-scroll. let mut drag_resend: Option<(BufferId, u64)> = None; if state.pointer_drag_active && let Some(dir) = state.edge_scroll_dir { let due = state .edge_scroll_last .is_none_or(|at| now.duration_since(at) >= EDGE_SCROLL_TICK); if due { state.edge_scroll_last = Some(now); let vp = state.scroll_by_lines(dir); if let Some(vp) = vp && let Some(client) = self.attach_client.as_ref() && let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation) { eprintln!("pmacs-gpu: edge-scroll send_viewport failed: {e}"); } let state = self.state.as_mut().expect("checked above"); if let Some((x, y)) = state.pointer_pos && let Some(byte) = state.hit_test_source_byte(x, y) && state.last_pointer_sent_byte != Some(byte) { state.last_pointer_sent_byte = Some(byte); state.note_pointer_round_trip(); if let Some(buffer_id) = state.current_buffer_id { drag_resend = Some((buffer_id, byte)); } } } let last = self .state .as_ref() .and_then(|s| s.edge_scroll_last) .unwrap_or(now); let tick_wake = last + EDGE_SCROLL_TICK; next_wake = Some(next_wake.map_or(tick_wake, |w| w.min(tick_wake))); } if let Some((buffer_id, byte)) = drag_resend { let mods = translate_mods(self.modifiers); self.send_pointer(buffer_id, byte, PointerKind::Drag, mods); } event_loop.set_control_flow(match next_wake { Some(at) => winit::event_loop::ControlFlow::WaitUntil(at), None => winit::event_loop::ControlFlow::Wait, }); } fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: AppEvent) { let Some(state) = self.state.as_mut() else { return; }; 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` // produces no styling until a viewport is declared. if let Some(ViewportSend { buffer_id, visible, generation, }) = follow_up && let Some(client) = self.attach_client.as_ref() && let Err(e) = client.send_viewport(buffer_id, visible, generation) { eprintln!("pmacs-gpu: send Viewport failed: {e}"); } state.release_timed_out_floor(); let ready_keys = state.take_ready_round_trip_keys(); if let Some(client) = self.attach_client.as_ref() { for (key, mods) in ready_keys { if debug_input() { eprintln!("pmacs-gpu flush_key: {key:?} mods={mods:?}"); } if let Err(e) = client.send_key(key, mods) { eprintln!("pmacs-gpu: flush send_key failed: {e}"); } } } } AppEvent::Attach(AttachEvent::Disconnected(reason)) => { eprintln!("pmacs-gpu: daemon disconnected ({reason})"); state.set_text("(daemon disconnected)"); } } } } /// Follow-up event the main loop fires back to the daemon after /// processing a message. Right now only Viewport (post-snapshot); /// later sessions extend this enum. #[derive(Debug, Clone, Copy)] struct ViewportSend { buffer_id: BufferId, visible: ByteRange, generation: u64, } #[derive(Debug)] struct CrdtOpSend { buffer_id: BufferId, op: CrdtOp, /// A scoped-viewport re-declaration when the optimistic insert /// scrolled the view (Enter on the bottom visible line). Sent /// after the op so the producer styles the newly visible range. viewport: Option, } /// How long an unconfirmed optimistic-cursor prediction may gate /// `CursorByte` acceptance and defer round-trip keys before the /// escape hatch releases it. Generous against a busy daemon tick; /// tiny against a human noticing wedged keys. const FLOOR_CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); /// Frontend-side double-click interval (Q#M1: the daemon cannot see /// pixels, so the frontend decides what a double-click is). Matches /// the TUI's `DOUBLE_CLICK_MAX_DELAY`. const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_millis(500); /// Wheel lines scrolled per `MouseScrollDelta::LineDelta` unit. const WHEEL_LINES_PER_TICK: f32 = 3.0; /// Byte range an optimistic Backspace/Delete removes at `cursor`, or /// `None` when it can't be predicted locally: buffer edge (the /// daemon's behavior is a no-op there anyway), a modifier variant /// (C-BS word-delete and friends are separate bindings), or a stale /// mid-codepoint cursor. The range is exactly one codepoint, matching /// `buffer.delete-backward` / `buffer.delete-forward`'s no-region /// behavior; region deletes are excluded upstream by the selection /// gate (they round-trip into `delete_region`). fn optimistic_delete_range( text: &str, cursor: usize, key: ProtocolKey, mods: Modifiers, ) -> Option<(usize, usize)> { if !mods.is_empty() { return None; } if cursor > text.len() || !text.is_char_boundary(cursor) { return None; } match key { ProtocolKey::Backspace => { let (start, _) = text[..cursor].char_indices().next_back()?; Some((start, cursor)) } ProtocolKey::Delete => { let ch = text[cursor..].chars().next()?; Some((cursor, cursor + ch.len_utf8())) } _ => None, } } /// The literal text `key` inserts when handled optimistically, or /// `None` for keys that must round-trip through the daemon. /// /// `Enter` and `Tab` qualify alongside printable chars because their /// default bindings (`buffer.newline` / `buffer.tab`) reduce to plain /// `insert_char(10)` / `insert_char(9)` — byte-identical to a /// self-insert, so the local application cannot diverge from what the /// daemon will do with the same op. Two caveats are the caller's job: /// `optimistic_crdt_insert` round-trips when an own-window selection /// is active (the daemon commands consume the region first — CUA /// type-over — which a raw op can't), and modified variants (`S-RET`, /// `C-TAB`, …) return `None` here: a keymap may bind them to anything. fn optimistic_insert_text(key: ProtocolKey, mods: Modifiers, chbuf: &mut [u8; 4]) -> Option<&str> { if !is_plain_text_modifiers(mods) { return None; } match key { ProtocolKey::Char(ch) if !ch.is_control() => Some(ch.encode_utf8(chbuf)), ProtocolKey::Enter if mods.is_empty() => Some("\n"), ProtocolKey::Tab if mods.is_empty() => Some("\t"), _ => None, } } /// A vertex buffer reused across frames: rewritten in place while the /// data fits, reallocated (with slack) when it grows. `render()` /// previously allocated fresh wgpu buffers for the background / caret /// / minimap quads on every frame. /// `(summary generation, surface width, surface height, scroll_top)` /// — everything the minimap quads depend on. type MinimapCacheKey = (u64, u32, u32, usize); struct ReusableVertexBuffer { buffer: Option, capacity: u64, } impl ReusableVertexBuffer { const fn new() -> Self { Self { buffer: None, capacity: 0, } } /// Upload `bytes`, reusing the existing allocation when possible. /// Returns the buffer to bind, or `None` for empty input. fn upload( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, label: &str, bytes: &[u8], ) -> Option<&wgpu::Buffer> { if bytes.is_empty() { return None; } let len = bytes.len() as u64; if self.buffer.is_none() || self.capacity < len { // Grow with slack so steady selection/minimap churn // settles into one allocation. let capacity = len.next_power_of_two(); self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size: capacity, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, })); self.capacity = capacity; } let buffer = self.buffer.as_ref().expect("just ensured"); queue.write_buffer(buffer, 0, bytes); Some(buffer) } } impl QuadRenderer { fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pmacs-gpu quad shader"), source: wgpu::ShaderSource::Wgsl(QUAD_SHADER.into()), }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pmacs-gpu quad pipeline layout"), bind_group_layouts: &[], immediate_size: 0, }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("pmacs-gpu quad pipeline"), layout: Some(&layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_main"), compilation_options: wgpu::PipelineCompilationOptions::default(), buffers: &[wgpu::VertexBufferLayout { array_stride: QUAD_VERTEX_STRIDE, step_mode: wgpu::VertexStepMode::Vertex, attributes: &QUAD_VERTEX_ATTRS, }], }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs_main"), compilation_options: wgpu::PipelineCompilationOptions::default(), targets: &[Some(wgpu::ColorTargetState { format: surface_format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: MultisampleState::default(), multiview_mask: None, cache: None, }); Self { pipeline } } fn render<'pass>( &'pass self, pass: &mut wgpu::RenderPass<'pass>, vertex_buffer: &'pass wgpu::Buffer, vertex_count: u32, ) { pass.set_pipeline(&self.pipeline); pass.set_vertex_buffer(0, vertex_buffer.slice(..)); pass.draw(0..vertex_count, 0..1); } } /// Diagnostic-squiggle pipeline (Q#W1). Parallel to [`QuadRenderer`] /// but with the [`SQUIGGLE_SHADER`] / [`SQUIGGLE_VERTEX_ATTRS`] vertex /// format that carries the per-fragment `uv` the sine fragment shader /// needs. struct SquiggleRenderer { pipeline: wgpu::RenderPipeline, } impl SquiggleRenderer { fn new(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pmacs-gpu squiggle shader"), source: wgpu::ShaderSource::Wgsl(SQUIGGLE_SHADER.into()), }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pmacs-gpu squiggle pipeline layout"), bind_group_layouts: &[], immediate_size: 0, }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("pmacs-gpu squiggle pipeline"), layout: Some(&layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_main"), compilation_options: wgpu::PipelineCompilationOptions::default(), buffers: &[wgpu::VertexBufferLayout { array_stride: SQUIGGLE_VERTEX_STRIDE, step_mode: wgpu::VertexStepMode::Vertex, attributes: &SQUIGGLE_VERTEX_ATTRS, }], }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs_main"), compilation_options: wgpu::PipelineCompilationOptions::default(), targets: &[Some(wgpu::ColorTargetState { format: surface_format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: MultisampleState::default(), multiview_mask: None, cache: None, }); Self { pipeline } } fn render<'pass>( &'pass self, pass: &mut wgpu::RenderPass<'pass>, vertex_buffer: &'pass wgpu::Buffer, vertex_count: u32, ) { pass.set_pipeline(&self.pipeline); pass.set_vertex_buffer(0, vertex_buffer.slice(..)); pass.draw(0..vertex_count, 0..1); } } impl State { #[allow(clippy::too_many_lines)] // linear GPU/font/surface setup; splitting would obscure ordering. fn new(event_loop: &ActiveEventLoop, initial_text: &str) -> Self { let window = Arc::new( event_loop .create_window( Window::default_attributes() .with_title("pmacs-gpu") .with_inner_size(winit::dpi::LogicalSize::new( f64::from(INITIAL_WIDTH), f64::from(INITIAL_HEIGHT), )), ) .expect("create window"), ); let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); let surface = instance .create_surface(window.clone()) .expect("create surface"); let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::LowPower, compatible_surface: Some(&surface), force_fallback_adapter: false, })) .expect("request_adapter"); let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("pmacs-gpu device"), required_features: wgpu::Features::empty(), required_limits: wgpu::Limits::default(), ..wgpu::DeviceDescriptor::default() })) .expect("request_device"); let inner_size = window.inner_size(); let surface_caps = surface.get_capabilities(&adapter); let surface_format = surface_caps .formats .iter() .copied() .find(wgpu::TextureFormat::is_srgb) .unwrap_or(surface_caps.formats[0]); let config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format: surface_format, width: inner_size.width.max(1), height: inner_size.height.max(1), present_mode: wgpu::PresentMode::Fifo, desired_maximum_frame_latency: 2, alpha_mode: surface_caps.alpha_modes[0], view_formats: vec![], }; surface.configure(&device, &config); let mut font_system = FontSystem::new(); font_system.db_mut().load_font_data(JETBRAINS_MONO.to_vec()); let swash_cache = SwashCache::new(); let cache = Cache::new(&device); let mut viewport = Viewport::new(&device, &cache); viewport.update( &queue, Resolution { width: config.width, height: config.height, }, ); let mut atlas = TextAtlas::new(&device, &queue, &cache, surface_format); let text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); // Q#CM1 — a second renderer so the menu draws as a top layer. let menu_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); // Q#MB1 — a third renderer for the minibuffer dropdown layer. let mb_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); let quad_renderer = QuadRenderer::new(&device, surface_format); let squiggle_renderer = SquiggleRenderer::new(&device, surface_format); // Smaller font in attach mode (file contents tend to be more // than one line); larger only fits "hello, pmacs"-shaped // strings. Picked metrics that look reasonable for code at // 800px wide. let mut buffer = Buffer::new(&mut font_system, Metrics::new(16.0, 22.0)); buffer.set_size( &mut font_system, Some(config.width as f32), Some(config.height as f32), ); let mut status_buffer = Buffer::new( &mut font_system, Metrics::new(STATUS_FONT_SIZE, STATUS_LINE_HEIGHT), ); status_buffer.set_size( &mut font_system, Some(config.width as f32), Some(STATUS_BAND_HEIGHT), ); let mut status_left_buffer = Buffer::new( &mut font_system, Metrics::new(STATUS_FONT_SIZE, STATUS_LINE_HEIGHT), ); status_left_buffer.set_size( &mut font_system, Some(config.width as f32), Some(STATUS_BAND_HEIGHT), ); let mut menu_buffer = Buffer::new( &mut font_system, Metrics::new(MENU_FONT_SIZE, MENU_LINE_HEIGHT), ); menu_buffer.set_size( &mut font_system, Some(MENU_MAX_WIDTH), Some(config.height as f32), ); let mut mb_buffer = Buffer::new( &mut font_system, Metrics::new(MB_DROP_FONT_SIZE, MB_DROP_LINE_HEIGHT), ); mb_buffer.set_size( &mut font_system, Some(MB_DROP_MAX_WIDTH), Some(config.height as f32), ); buffer.set_text( &mut font_system, initial_text, &Attrs::new().family(Family::Name("JetBrains Mono")), Shaping::Advanced, None, ); buffer.shape_until_scroll(&mut font_system, false); let (current_line_starts, current_line_char_starts) = line_offset_tables(initial_text); Self { window, device, queue, surface, config, font_system, swash_cache, viewport, atlas, text_renderer, quad_renderer, squiggle_renderer, buffer, current_text: initial_text.to_owned(), current_line_starts, current_line_char_starts, current_line_shapes: minimap_line_shapes(initial_text), loro_doc: None, loro_text_delta_batches: None, loro_text_subscription: None, current_buffer_id: None, current_spans: Vec::new(), current_decorations: Vec::new(), current_adornments: Vec::new(), current_summary: None, peer_presences: HashMap::new(), own_cursor: None, scroll_top: 0, view_range: (0, 0), last_viewport_sent: None, local_frontend_id: None, dispatch_idle: false, clipboard: None, cursor_fresh: false, optimistic_cursor_floor: None, deferred_round_trip_keys: Vec::new(), optimistic_floor_set_at: None, unconfirmed_edits: Vec::new(), current_hit_runs: Vec::new(), projected_line_starts: vec![0], pointer_pos: None, pointer_drag_active: false, last_pointer_sent_byte: None, last_pointer_down: None, minimap_scrub_active: false, edge_scroll_dir: None, edge_scroll_last: None, styled_redraw_deadline: None, hit_map_dirty: false, line_chunk_cache: Vec::new(), shaped_top: 0, bg_vertex_buffer: ReusableVertexBuffer::new(), squiggle_vertex_buffer: ReusableVertexBuffer::new(), caret_vertex_buffer: ReusableVertexBuffer::new(), minimap_vertex_buffer: ReusableVertexBuffer::new(), status_buffer, status_text: String::new(), status_left_buffer, status_left_text: String::new(), status_facts: None, search_prompt: None, minibuffer: None, menu: None, menu_anchor_px: (0.0, 0.0), menu_buffer, menu_text_renderer, menu_bg_vertex_buffer: ReusableVertexBuffer::new(), mb_buffer, mb_text_renderer, mb_bg_vertex_buffer: ReusableVertexBuffer::new(), minimap_cache: None, } } 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:?}"); } } /// `true` while the daemon is intercepting keystrokes — an active /// incremental search (Q#SR5), surfaced by a live `SearchPrompt`, or /// the daemon reporting non-idle (`DispatchIdle { idle: false }` for /// a minibuffer / pending prefix). In this state the GUI round-trips /// every key to the daemon's handler instead of optimistically /// applying it to the buffer. fn daemon_intercepts_keys(&self) -> bool { // Q#CM1 / Q#MB1 — an open menu or minibuffer shadows the keymap // like search: every key round-trips so the daemon's // `dispatch_menu_key` / minibuffer handler drives it. self.search_prompt.is_some() || self.menu.is_some() || self.minibuffer.is_some() || !self.dispatch_idle } /// Shared eligibility gates for the optimistic edit paths /// (insert + delete). `None` ⇒ the key must round-trip: /// - dispatcher busy (minibuffer/prefix flows own the keys), or /// the cursor isn't authoritative; /// - CUA region semantics: an own-window selection means typing /// replaces and Backspace/Delete consume the region — those /// semantics live in the daemon's region-aware commands, which /// a raw `CrdtOp` bypasses. (Our own selection arrives as a /// `Selection` decoration; peer selections live in /// `peer_presences` and don't gate.) /// - bookkeeping: no frontend id / cursor / matching buffer / /// replica doc, or the peer id can't be set. fn optimistic_edit_eligible(&self) -> Option<(OwnCursor, u64)> { if !self.dispatch_idle || !self.cursor_fresh { return None; } if self .current_decorations .iter() .any(|d| d.kind == DecorationKind::Selection) { return None; } let frontend_id = self.local_frontend_id?; let own = self.own_cursor?; if self.current_buffer_id != Some(own.buffer_id) { return None; } let doc = self.loro_doc.as_ref()?; let peer_id = frontend_id.0; if doc.peer_id() != peer_id && let Err(e) = doc.set_peer_id(peer_id) { eprintln!("pmacs-gpu: failed to set optimistic Loro peer id: {e:?}"); return None; } Some((own, peer_id)) } fn optimistic_crdt_insert(&mut self, key: ProtocolKey, mods: Modifiers) -> Option { let mut chbuf = [0u8; 4]; let insert = optimistic_insert_text(key, mods, &mut chbuf)?; let (own, peer_id) = self.optimistic_edit_eligible()?; let cursor = usize::try_from(own.byte).ok()?; if cursor > self.current_text.len() || !self.current_text.is_char_boundary(cursor) { return None; } let doc = self.loro_doc.as_ref()?; let delta_batches = self.loro_text_delta_batches.clone()?; clear_loro_text_delta_batches(&delta_batches); let before = doc.oplog_vv(); if let Err(e) = doc .get_text(LORO_TEXT_CONTAINER) .insert_utf8(cursor, insert) { eprintln!("pmacs-gpu: optimistic insert failed: {e:?}"); return None; } let bytes = doc .export(ExportMode::updates(&before)) .expect("export local optimistic Loro update"); let drained = drain_loro_text_delta_batches(&delta_batches); let predicted = OwnCursor { buffer_id: own.buffer_id, byte: own.byte.saturating_add(insert.len() as u64), }; Some(self.finish_optimistic_edit(&drained, predicted, peer_id, bytes)) } /// Optimistic single-codepoint Backspace / Delete. Mirrors the /// insert path: the daemon's `buffer.delete-backward/-forward` /// no-region behavior is exactly "delete one codepoint", so the /// local application cannot diverge; region deletes are excluded /// by the selection gate (they round-trip into `delete_region`), /// and modified variants (C-BS word delete, …) round-trip via /// `optimistic_delete_range` returning `None`. The daemon applies /// the op through its single-delete CRDT hot path. fn optimistic_crdt_delete(&mut self, key: ProtocolKey, mods: Modifiers) -> Option { if !matches!(key, ProtocolKey::Backspace | ProtocolKey::Delete) { return None; } let (own, peer_id) = self.optimistic_edit_eligible()?; let cursor = usize::try_from(own.byte).ok()?; let (start, end) = optimistic_delete_range(&self.current_text, cursor, key, mods)?; let doc = self.loro_doc.as_ref()?; let delta_batches = self.loro_text_delta_batches.clone()?; clear_loro_text_delta_batches(&delta_batches); let before = doc.oplog_vv(); if let Err(e) = doc .get_text(LORO_TEXT_CONTAINER) .delete_utf8(start, end - start) { eprintln!("pmacs-gpu: optimistic delete failed: {e:?}"); return None; } let bytes = doc .export(ExportMode::updates(&before)) .expect("export local optimistic Loro update"); let drained = drain_loro_text_delta_batches(&delta_batches); let predicted = OwnCursor { buffer_id: own.buffer_id, byte: start as u64, }; Some(self.finish_optimistic_edit(&drained, predicted, peer_id, bytes)) } /// Common tail of the optimistic edit paths: patch the local text /// from the drained Loro deltas (journaling them for /// incoming-frame translation), predict the cursor + arm the /// confirmation floor, follow the caret, and package the wire op. fn finish_optimistic_edit( &mut self, drained: &[Vec], predicted: OwnCursor, peer_id: u64, bytes: Vec, ) -> CrdtOpSend { if drained.is_empty() { let text = self .loro_doc .as_ref() .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); if let Some(text) = text { self.set_text(&text); } // Cache rebuilt wholesale — there are no translated // anchors left for frame translation to protect. self.unconfirmed_edits.clear(); } else { match self.apply_loro_text_delta_batches(drained) { Ok(edits) => { // Journal this keystroke so producer frames the // daemon computed before integrating it can be // translated on arrival (see `unconfirmed_edits`). // The scalar is read *after* the local edit, so // any frame stamped at or beyond it includes us. let scalar = self.loro_doc.as_ref().map_or(0, loro_version_scalar); self.unconfirmed_edits .extend(edits.into_iter().map(|e| (scalar, e))); } Err(reason) => { eprintln!( "pmacs-gpu: optimistic text update failed ({reason}); falling back to \ full materialization" ); let text = self .loro_doc .as_ref() .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); if let Some(text) = text { self.set_text(&text); } self.unconfirmed_edits.clear(); } } } self.own_cursor = Some(predicted); self.optimistic_cursor_floor = Some(predicted); self.optimistic_floor_set_at = Some(std::time::Instant::now()); // Follow the caret NOW rather than when the daemon's // `CursorByte` confirms — an optimistic Enter on the bottom // visible line (or a Backspace pulling the caret above the // top) moves it outside the slice, and waiting a round trip // to scroll reads as a hitch. let viewport = if self.scroll_to_cursor() { self.rebuild_lines_reusing_scroll(); self.viewport_send_if_changed(predicted.buffer_id) } else { None }; CrdtOpSend { buffer_id: predicted.buffer_id, op: CrdtOp { peer_id, bytes }, viewport, } } fn mark_cursor_stale_after_round_trip(&mut self) { self.cursor_fresh = false; } fn apply_loro_text_delta_batches( &mut self, delta_batches: &[Vec], ) -> Result, &'static str> { let line_count_before = self.current_line_starts.len(); 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); // Q#R1 — the keystroke case (one edit, no line-structure // change) re-shapes only the affected BufferLine; everything // else falls back to the full slice reshape. let single_line_edit = edits.len() == 1 && self.current_line_starts.len() == line_count_before && !self.current_text [edits[0].start as usize..(edits[0].start + edits[0].inserted_len) as usize] .contains('\n'); if !(single_line_edit && self.try_reshape_line(edits[0])) { self.reshape(); } Ok(edits) } fn translate_cached_anchors(&mut self, edits: &[TextProjectionEdit]) { for edit in edits { translate_style_spans(&mut self.current_spans, *edit); translate_decorations(&mut self.current_decorations, *edit); translate_inline_adornments(&mut self.current_adornments, *edit); } } /// Drop journal entries already reflected in a producer frame /// stamped `generation` — see the `unconfirmed_edits` field docs. fn prune_unconfirmed_edits(&mut self, generation: u64) { self.unconfirmed_edits .retain(|(scalar, _)| *scalar > generation); } fn optimistic_floor_timed_out(&self) -> bool { self.optimistic_floor_set_at .is_some_and(|armed| armed.elapsed() >= FLOOR_CONFIRM_TIMEOUT) } /// Escape hatch: release a floor the daemon never confirmed so /// deferred round-trip keys can't wedge forever. Dropping /// `cursor_fresh` falls the GPU back to round-trip mode until the /// next `CursorByte` resynchronizes the cursor. fn release_timed_out_floor(&mut self) { if self.optimistic_cursor_floor.is_some() && self.optimistic_floor_timed_out() { eprintln!( "pmacs-gpu: optimistic cursor unconfirmed after {FLOOR_CONFIRM_TIMEOUT:?}; \ falling back to round-trip input" ); self.optimistic_cursor_floor = None; self.optimistic_floor_set_at = None; self.cursor_fresh = false; } } fn defer_round_trip_key_if_needed(&mut self, key: ProtocolKey, mods: Modifiers) -> bool { if self.optimistic_cursor_floor.is_none() && self.deferred_round_trip_keys.is_empty() { return false; } self.cursor_fresh = false; self.deferred_round_trip_keys.push((key, mods)); true } fn take_ready_round_trip_keys(&mut self) -> Vec<(ProtocolKey, Modifiers)> { if self.optimistic_cursor_floor.is_some() || self.deferred_round_trip_keys.is_empty() { return Vec::new(); } self.cursor_fresh = false; std::mem::take(&mut self.deferred_round_trip_keys) } /// Replace the rendered text with `text` and request a redraw. /// Returns `false` when `text` is byte-identical to the current /// rendering (avoids the re-shape cost when an unchanged buffer /// ticks). /// /// Replaces the rope text and routes through `reshape` so the /// rich-text rendering uses the current spans, decorations, and /// inline adornments. When called from the `CrdtOp` path (text /// shifted under existing source anchors) those anchors are /// momentarily stale relative to the new byte positions — /// `reshape` clamps via `range.end.min(text_len)` so rendering is /// safe, but visual styling may be off until the daemon's next /// semantic frame catches up. A real artifact; classified as a /// known Phase A limitation rather than a bug. fn set_text(&mut self, text: &str) -> bool { if self.current_text == text { return false; } self.current_text.clear(); self.current_text.push_str(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 } /// Apply one `InstanceMessage`; return a follow-up /// `ViewportSend` if the message requires the main loop to fire /// one back at the daemon. /// /// Session 4 introduced four variants; session 5 adds /// `Decorations`: /// - `BufferSnapshot` — bootstrap a fresh `LoroDoc`, extract text, /// request the daemon scope styling to the new buffer (return a /// Viewport send-back). /// - `CrdtOp` — apply incremental updates to the doc; text /// 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 /// `DecorationKind` set (diagnostics, selection, current line, /// search match). Session 5 renders diagnostic kinds as fg color /// overrides; background-kind decorations are accumulated but /// not painted (see session 5's deferred quad-pipeline finding). /// - `InlineAdornments` — replace the scoped virtual-text set and /// reshape the display projection. Session 6 consumes `AtOffset` /// text adornments (LSP inlay hints); other placements/content /// remain explicitly deferred. /// - `FileStyleSummary` — replace the whole-file minimap summary. /// Session 7 renders it as a right-side per-line style overview /// plus a visible-window affordance. /// - `Goodbye` — surfaced via the reader thread's clean-EOF path, /// not handled here. /// /// The grid variants (`CellDelta`, `Cursor`, `CursorByte`) are /// ignored — pmacs-gpu lays out locally and tracks the cursor via /// `PresenceUpdate` (session 9.3). Remaining semantic variants land /// in subsequent Phase A sessions. /// Lazily-created OS clipboard handle (Q#CM6). Returns `None` if the /// platform clipboard can't be opened, so callers degrade to no-ops. fn os_clipboard(&mut self) -> Option<&mut arboard::Clipboard> { if self.clipboard.is_none() { match arboard::Clipboard::new() { Ok(c) => self.clipboard = Some(c), Err(e) => { eprintln!("pmacs-gpu: OS clipboard unavailable: {e}"); return None; } } } self.clipboard.as_mut() } /// Read the OS clipboard as bytes (for Ctrl-V → `Paste`). `None` on /// any failure (empty / non-text / unavailable). fn read_os_clipboard(&mut self) -> Option> { match self.os_clipboard()?.get_text() { Ok(s) => Some(s.into_bytes()), Err(e) => { eprintln!("pmacs-gpu: clipboard read failed: {e}"); None } } } /// Write bytes to the OS clipboard (for an inbound /// `Signal::Clipboard` after a daemon copy/cut). Lossy UTF-8; the /// daemon only ever sends valid document text. fn write_os_clipboard(&mut self, bytes: &[u8]) { let text = String::from_utf8_lossy(bytes).into_owned(); if let Some(c) = self.os_clipboard() && let Err(e) = c.set_text(text) { eprintln!("pmacs-gpu: clipboard write failed: {e}"); } } #[allow(clippy::too_many_lines)] // per-variant match dispatcher; one arm per InstanceMessage. fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option { match msg { InstanceMessage::BufferSnapshot { buffer_id, 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_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 // buffer is authoritative. self.current_spans.clear(); self.current_decorations.clear(); self.current_adornments.clear(); self.current_summary = None; // Peer cursors and our own cursor are anchored in the // prior buffer's coordinate space; drop them so a stale // offset can't paint against the new rope before the // next PresenceUpdate / CursorByte arrives. self.peer_presences.clear(); self.own_cursor = None; self.cursor_fresh = false; self.optimistic_cursor_floor = None; self.optimistic_floor_set_at = None; self.deferred_round_trip_keys.clear(); self.unconfirmed_edits.clear(); // New buffer ⇒ back to the top, and force a viewport // re-declaration for the new buffer's scoped range. self.scroll_top = 0; self.last_viewport_sent = None; if !self.set_text(&text) { self.reshape(); } self.viewport_send_if_changed(buffer_id) } InstanceMessage::CrdtOp { buffer_id, op } => { if self.current_buffer_id != Some(buffer_id) { // Edit op for a different buffer than we currently // render. Ignore for now (multi-buffer is a future // session); when buffer-switching lands we'll // index ops by buffer. return None; } let Some(doc) = self.loro_doc.as_ref() else { // Mid-attach race: ops before snapshot. The // snapshot will have the ops baked in. 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 // generation-transition fix) ships `full=true` // styling for buffers whose generation advanced, so // the next message replaces the stale items // wholesale via `replace_style_spans` / // `replace_decorations`. The single-frame gap // between CrdtOp arrival and that next frame paints // styling at stale byte positions — the session-4 // documented "one-frame stale" artifact. A previous // attempt to fix it by clearing both vectors here // (`49785c4`) was reverted because the producer's // *incremental* updates ship dirty-range spans only, // and an emptied cache loses the non-dirty viewport // styling entirely. // // InlineAdornments use whole-set suppression rather // than dirty segments, so the same ownership rule // applies here: keep the last set until the producer // sends a replacement. Session 8 closed the stale // inlay case producer-side: `didChange` marks the // inlay store stale, and the producer sends one empty // replacement to clear cached virtual text until a // fresh `textDocument/inlayHint` response arrives. let delta_batches = delta_batches .as_ref() .map(drain_loro_text_delta_batches) .unwrap_or_default(); if delta_batches.is_empty() { if !import_status.success.is_empty() { let text = self .loro_doc .as_ref() .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); if let Some(text) = text { self.set_text(&text); } self.unconfirmed_edits.clear(); } } else { match self.apply_loro_text_delta_batches(&delta_batches) { Ok(edits) => { // A daemon-originated edit shifts the text // under any still-unconfirmed optimistic // edits. Rebase the journal's anchors so // frames that include this edit (but not // ours) translate correctly. Entries are // inserts (start == old_end) or // single-codepoint deletes; both rebase by // position translation, clamped so a range // can't invert. for incoming in &edits { for (_, pending) in &mut self.unconfirmed_edits { pending.start = translate_byte_position(pending.start, *incoming); pending.old_end = translate_byte_position(pending.old_end, *incoming) .max(pending.start); } } } Err(reason) => { eprintln!( "pmacs-gpu: incremental CRDT text update failed ({reason}); \ falling back to full materialization" ); let text = self .loro_doc .as_ref() .map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string()); if let Some(text) = text { self.set_text(&text); } self.unconfirmed_edits.clear(); } } } // Local typing usually shifts only the viewport's end // byte while the top visible source line stays fixed. // The declared range includes overscan, and the daemon's // generation bump already forces a full style resync, so // re-declaring on every byte is mostly write amplification. // Re-declare here only if the viewport origin moved (for // example because an edit before `scroll_top` shifted the // top line); scroll/resize/snapshot still send exact ranges. self.viewport_send_if_origin_changed(buffer_id) } InstanceMessage::StyleSpans { buffer_id, generation, 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 { self.merge_style_spans(segments); } // Re-shape only lines whose styling actually changed // — a parse-settle frame after a burst usually // recolors a line or two, and a scroll-triggered // resync only the newly exposed ones. self.refresh_changed_lines(); None } InstanceMessage::Decorations { buffer_id, 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); if full { self.replace_decorations(segments); } else { self.merge_decorations(segments); } // Every decoration kind is now a quad (backgrounds // for Selection/CurrentLine, underline bars for the // diagnostics — the fg-recolor path retired with T // M4.6 parity), and quads rebuild cheaply per frame // in `render()`. No decoration change needs a // reshape, so none triggers one — diagnostic // publishes no longer pay set_rich_text + // shape_until_scroll. self.window.request_redraw(); None } InstanceMessage::InlineAdornments { buffer_id, items } => { if self.current_buffer_id != Some(buffer_id) { return None; } self.current_adornments = items; self.current_adornments.sort_by_key(|a| a.at); self.refresh_changed_lines(); None } InstanceMessage::FileStyleSummary { buffer_id, generation, lines, } => { self.apply_file_style_summary(buffer_id, generation, lines); None } // Q#S1 (protocol v8) — the wire-authoritative half of the // status band: name, modified, whole-file diag counts. InstanceMessage::StatusFacts { buffer_id, name, modified, diag_errors, diag_warnings, } => { self.status_facts = Some(StatusFactsLocal { buffer_id, name, modified, diag_errors, diag_warnings, }); self.window.request_redraw(); None } // Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10). // `query: None` clears the band (search ended); `Some` shows // `[Regex] I-search: (n/m)` on the band's left side. // The matches themselves arrive as SearchMatch decorations // and the keys round-trip via the intercept gate, so this // handler only drives the prompt text. InstanceMessage::SearchPrompt { buffer_id, query, active, total, regex, invalid, } => { self.search_prompt = query.map(|q| SearchPromptLocal { buffer_id, query: q, active, total, regex, invalid, }); self.window.request_redraw(); None } // Session 9.3 — peer presence. The editing frontend's // cursor + selection drive the `CurrentLine` / `Selection` // washes for this read-only mirror (finding QB1). Store // per source frontend; a redraw recomputes the background // rects from `peer_presences`. We never receive our own // (daemon sender exclusion). InstanceMessage::PresenceUpdate { frontend_id, buffer_id, cursor, selection, } => { // Run with `PMACS_GPU_DEBUG_PRESENCE=1` to confirm peer // presence is arriving and routed to the right buffer. // A `buf != current` line means the peer is on a buffer // this mirror isn't displaying (no wash expected); no // line at all means the message isn't reaching us. if debug_presence() { eprintln!( "pmacs-gpu presence: fid={frontend_id:?} buf={buffer_id:?} \ current={:?} cursor={cursor} sel={selection:?}", self.current_buffer_id ); } self.peer_presences.insert( frontend_id, PeerPresence { buffer_id, cursor, selection, }, ); self.window.request_redraw(); None } // Session B1 — our own cursor. The daemon emits this per // tick for the replica; the caret + own-window decorations // follow it. Only meaningful once we send Key events that // move it. InstanceMessage::CursorByte { buffer_id, byte_pos, } => { if debug_input() { eprintln!( "pmacs-gpu cursor: buf={buffer_id:?} byte={byte_pos} \ current={:?} match={}", self.current_buffer_id, self.current_buffer_id == Some(buffer_id) ); } if let Some(floor) = self.optimistic_cursor_floor { // With deletes in the optimistic set the predicted // cursor is no longer monotonic, so only the EXACT // predicted byte (or a cursor for another buffer) // confirms; any other value is an in-flight frame // from before our unconfirmed edits. The timeout // hatch accepts daemon truth if confirmation never // comes (op dropped, peer raced our cursor). let confirmed = floor.buffer_id != buffer_id || byte_pos == floor.byte; if confirmed || self.optimistic_floor_timed_out() { self.optimistic_cursor_floor = None; self.optimistic_floor_set_at = None; } else { if debug_input() { eprintln!( "pmacs-gpu cursor: ignored stale in-flight position \ buf={buffer_id:?} byte={byte_pos} predicted={}", floor.byte ); } return None; } } let arrived = OwnCursor { buffer_id, byte: byte_pos, }; let moved = self.own_cursor != Some(arrived); self.own_cursor = Some(arrived); self.cursor_fresh = self.current_buffer_id == Some(buffer_id); // Session S1 — keep the caret on screen (Q#S2). When the // cursor leaves the visible slice (arrows past an edge, // PageUp/Down), scroll to follow it, re-shape the new // slice, and re-declare the scoped Viewport so the // producer ships spans for what's now visible. // // Only when the cursor MOVED. The daemon attaches a // CursorByte to every frame it produces — including // the frames our own Viewport sends trigger — so an // unconditional follow snapped the viewport back to // a stationary cursor on every minimap jump / scrub // (and on any wheel scroll past the cursor's screen): // jump → Viewport → frame + re-announced CursorByte → // snap, in a loop. Scrolling away from a cursor that // isn't moving is the user's prerogative. if moved && self.scroll_to_cursor() { // Pure scroll: retained lines keep their shape // caches; only newly exposed lines shape. self.rebuild_lines_reusing_scroll(); if let Some(vp) = self.viewport_send_if_changed(buffer_id) { return Some(vp); } } self.window.request_redraw(); None } InstanceMessage::DispatchIdle { idle } => { self.dispatch_idle = idle; None } // Q#CM6 — a daemon copy/cut published the region; write it to // the OS clipboard via arboard so other apps can paste it. InstanceMessage::Signal(InstanceSignal::Clipboard(bytes)) => { self.write_os_clipboard(&bytes); None } // Q#CM1 — the context menu's rows + highlight. Empty rows // close it; otherwise anchor the popup at the remembered // right-click pixel. InstanceMessage::MenuPrompt { rows, active, .. } => { self.menu = if rows.is_empty() { None } else { Some(MenuLocal { rows, active, anchor_px: self.menu_anchor_px, }) }; self.window.request_redraw(); None } // Q#MB1 — the minibuffer prompt/input/candidates. `prompt: // None` closes it. InstanceMessage::MinibufferPrompt { prompt, input, cursor, candidates, selected, total, } => { self.minibuffer = prompt.map(|prompt| MinibufferLocal { prompt, input, cursor, candidates, selected, total, }); self.window.request_redraw(); None } _ => None, } } /// A `ViewportSend` for the current `view_range` if it differs from /// the last one declared, else `None` (Q#S5 coalescing). `generation` /// is 0 — the producer's full-resync triggers on the visible-range /// change and on the CRDT generation bump, not this field. fn viewport_send_if_changed(&mut self, buffer_id: BufferId) -> Option { if self.last_viewport_sent == Some(self.view_range) { return None; } self.last_viewport_sent = Some(self.view_range); let (start, end) = self.view_range; Some(ViewportSend { buffer_id, visible: ByteRange { start, end }, generation: 0, }) } /// Edit-path variant of [`Self::viewport_send_if_changed`]. For /// ordinary insertion/deletion inside the visible slice, only the /// end byte moves; sending that on every `CrdtOp` doubles the /// frontend-to-daemon write traffic while the producer already has /// a CRDT generation transition to trigger a full viewport resync. /// If the start byte moves, the top visible line itself shifted, so /// the daemon needs a fresh declaration. fn viewport_send_if_origin_changed(&mut self, buffer_id: BufferId) -> Option { let Some((last_start, last_end)) = self.last_viewport_sent else { return self.viewport_send_if_changed(buffer_id); }; if last_start != self.view_range.0 { return self.viewport_send_if_changed(buffer_id); } // End drift: typing grows the slice end while the declared // end stays put, and the daemon clips styling to the declared // range. Long unbroken typing would eat through the bottom // overscan and the deepest lines would lose styling — once // the drift exceeds half the overscan (in lines), re-declare. let starts = &self.current_line_starts; let declared_line = starts.partition_point(|&s| s <= last_end); let current_line = starts.partition_point(|&s| s <= self.view_range.1); if current_line.abs_diff(declared_line) * 2 > SCROLL_OVERSCAN { return self.viewport_send_if_changed(buffer_id); } None } /// Adjust `scroll_top` so the own cursor's source line is within the /// visible window (Q#S2). Returns whether `scroll_top` changed (in /// which case the caller re-shapes + re-declares the viewport). fn scroll_to_cursor(&mut self) -> bool { let Some(own) = self.own_cursor else { return false; }; if self.current_buffer_id != Some(own.buffer_id) { return false; } let line_starts = &self.current_line_starts; let cursor = own.byte.min(self.current_text.len() as u64); // Cursor's source line = largest i with line_starts[i] <= cursor. let cursor_line = line_starts .partition_point(|&s| s <= cursor) .saturating_sub(1); let visible = estimated_visible_lines(self.config.height).max(1); let old = self.scroll_top; if cursor_line < self.scroll_top { self.scroll_top = cursor_line; } else if cursor_line >= self.scroll_top + visible { self.scroll_top = cursor_line + 1 - visible; } self.scroll_top != old } /// Resolve a window-pixel position to an **absolute source byte** /// (Q#M2): pixel → cosmic-text hit (shaped line + byte within /// line) → projected byte → run map → slice byte → + `vstart`. /// `None` when no buffer is attached or the position is outside /// anything hit-testable. fn hit_test_source_byte(&mut self, x: f64, y: f64) -> Option { self.current_buffer_id?; if self.hit_map_dirty { // Q#R2 — a per-line reshape deferred this; rebuild from // the same chunk source the shaped buffer was built from. let (vstart, vend) = self.view_range; let rich = clipped_chunks_for_range( &self.current_text, &self.current_spans, &self.current_adornments, vstart, vend, ); let (hit_runs, projected_line_starts) = build_hit_runs(&rich); self.current_hit_runs = hit_runs; self.projected_line_starts = projected_line_starts; self.hit_map_dirty = false; } let rel_x = x as f32 - TEXT_LEFT; let rel_y = y as f32 - TEXT_TOP; let cursor = self.buffer.hit(rel_x, rel_y)?; let line_start = *self.projected_line_starts.get(cursor.line)?; let projected = line_start + cursor.index as u64; let slice_byte = projected_to_source(&self.current_hit_runs, projected)?; let (vstart, vend) = self.view_range; Some((vstart + slice_byte).min(vend)) } /// Wheel scroll (local-only — the GPU owns the viewport; no wire /// event exists or is needed). Positive `delta` scrolls down. fn scroll_by_lines(&mut self, delta: i64) -> Option { let max_top = self.current_line_starts.len().saturating_sub(1); let new_top = self .scroll_top .saturating_add_signed(delta as isize) .min(max_top); if new_top == self.scroll_top { return None; } self.scroll_top = new_top; self.rebuild_lines_reusing_scroll(); self.current_buffer_id .and_then(|bid| self.viewport_send_if_changed(bid)) } /// True when the pixel position lies inside the minimap band /// (Q#M6). Presses here are consumed locally and never become /// `Pointer` events. fn in_minimap_band(&self, x: f64, y: f64) -> bool { minimap_band_contains(x as f32, y as f32, self.config.width, self.config.height) } /// Popup width in pixels (Q#CM1) — widest label estimated from a /// fixed per-char advance, padded, clamped. Used by both hit-testing /// and the bg quad so they line up. fn menu_width_px(menu: &MenuLocal) -> f32 { let max_chars = menu .rows .iter() .map(|r| r.label.chars().count()) .max() .unwrap_or(0); (max_chars as f32 * MENU_CHAR_W + 2.0 * MENU_PAD_X).clamp(MENU_MIN_WIDTH, MENU_MAX_WIDTH) } /// Hit-test a pixel against the open popup (Q#CM1). Returns /// `(row_index, is_item)` when inside the popup rectangle, or `None` /// when outside (or no menu open). fn menu_hit(&self, x: f64, y: f64) -> Option<(u32, bool)> { let menu = self.menu.as_ref()?; let (ax, ay) = menu.anchor_px; let w = f64::from(Self::menu_width_px(menu)); let h = menu.rows.len() as f64 * f64::from(MENU_ROW_HEIGHT); if x < ax || x >= ax + w || y < ay || y >= ay + h { return None; } let row = (((y - ay) / f64::from(MENU_ROW_HEIGHT)).floor() as usize).min(menu.rows.len() - 1); Some((row as u32, !menu.rows[row].separator)) } /// Center the viewport on the source line the minimap pixel `y` /// maps to — the inverse of the painter's linear line→y /// interpolation. Reuses [`Self::scroll_by_lines`] for the /// clamp / rebuild / viewport-send plumbing. fn minimap_jump_to(&mut self, y: f64) -> Option { let target = minimap_y_to_line(y as f32, self.config.height, self.current_line_starts.len())?; let centered = target.saturating_sub(estimated_visible_lines(self.config.height) / 2); let delta = i64::try_from(centered).unwrap_or(i64::MAX) - i64::try_from(self.scroll_top).unwrap_or(i64::MAX); self.scroll_by_lines(delta) } /// Q#R1 — per-line incremental reshape for a single-line text /// edit: rebuild ONE `BufferLine` instead of re-shaping the whole /// visible slice. Returns `false` when the edit needs the full /// `reshape` (slice origin moved, edited line outside the shaped /// slice, exotic paragraph separators that the full path would /// have split on). The caller has already established the edit is /// single-line (line count unchanged, no `\n` inserted). fn try_reshape_line(&mut self, edit: TextProjectionEdit) -> bool { let (vstart, vend) = self.visible_byte_range(); if vstart != self.view_range.0 { // The slice origin moved (edit before the viewport): the // whole slice shifts; surgery can't help. return false; } if edit.start >= vend { // Entirely past the visible slice: no shaped line's // content changes; offsets are clip-rebased per frame. self.view_range = (vstart, vend); self.hit_map_dirty = true; self.window.request_redraw(); return true; } let line_idx = self .current_line_starts .partition_point(|&s| s <= edit.start) .saturating_sub(1); let line_start = self.current_line_starts[line_idx]; if line_start < vstart { return false; } let next_start = self.current_line_starts.get(line_idx + 1).copied(); let content_end = next_start .map_or(self.current_text.len() as u64, |n| n.saturating_sub(1)) .min(vend); let Some(shaped_idx) = line_idx.checked_sub(self.shaped_top) else { return false; }; if shaped_idx >= self.buffer.lines.len() || shaped_idx >= self.line_chunk_cache.len() { // E.g. typing on the phantom empty line after a trailing // newline — no BufferLine exists for it; full reshape // handles those shapes correctly. return false; } let chunks = self.chunks_for_line(line_start, content_end); self.buffer.lines[shaped_idx] = line_from_chunks(&chunks); self.line_chunk_cache[shaped_idx] = chunks; self.buffer.shape_until_scroll(&mut self.font_system, false); self.view_range = (vstart, vend); self.hit_map_dirty = true; self.window.request_redraw(); true } /// `(top, [(line_start, content_end)])` for the slice /// `[vstart, vend)`: one entry per shaped line, content excluding /// the `\n`. A line starting exactly at `vend` (incl. the phantom /// line after a trailing `\n`) is not shaped — matching the line /// splitting `set_rich_text` used to do. fn slice_line_ranges(&self, vstart: u64, vend: u64) -> (usize, Vec<(u64, u64)>) { let starts = &self.current_line_starts; let n = starts.len(); let top = self.scroll_top.min(n.saturating_sub(1)); let mut ranges = Vec::new(); let mut idx = top; while idx < n { let ls = starts[idx]; if ls >= vend { break; } let ce = starts .get(idx + 1) .map_or(self.current_text.len() as u64, |&next| next - 1) .min(vend); ranges.push((ls, ce)); idx += 1; } if ranges.is_empty() { ranges.push((vstart, vstart)); } (top, ranges) } fn chunks_for_line(&self, line_start: u64, content_end: u64) -> Vec { clipped_chunks_for_range( &self.current_text, &self.current_spans, &self.current_adornments, line_start, content_end, ) } /// Rebuild the shaped slice, reusing any retained line whose /// absolute index was already shaped (pure scroll: content and /// styling unchanged for retained lines, their shape caches /// survive — only newly exposed lines pay shaping). Falls back to /// building everything when nothing overlaps. Every builder keeps /// `line_chunk_cache` current, so reuse is always sound here. fn rebuild_lines_reusing_scroll(&mut self) { let (vstart, vend) = self.visible_byte_range(); self.view_range = (vstart, vend); let (new_top, ranges) = self.slice_line_ranges(vstart, vend); let old_top = self.shaped_top; let mut old_lines: Vec> = std::mem::take(&mut self.buffer.lines) .into_iter() .map(Some) .collect(); let mut old_cache: Vec>> = std::mem::take(&mut self.line_chunk_cache) .into_iter() .map(Some) .collect(); let mut lines = Vec::with_capacity(ranges.len()); let mut cache = Vec::with_capacity(ranges.len()); let mut any_reused = false; for (i, &(ls, ce)) in ranges.iter().enumerate() { let abs = new_top + i; let reused = abs.checked_sub(old_top).and_then(|j| { if j < old_lines.len() && j < old_cache.len() { old_lines[j].take().zip(old_cache[j].take()) } else { None } }); if let Some((line, chunks)) = reused { any_reused = true; lines.push(line); cache.push(chunks); } else { let chunks = self.chunks_for_line(ls, ce); lines.push(line_from_chunks(&chunks)); cache.push(chunks); } } self.buffer.lines = lines; self.line_chunk_cache = cache; self.shaped_top = new_top; self.buffer .set_scroll(glyphon::cosmic_text::Scroll::default()); self.buffer.shape_until_scroll(&mut self.font_system, false); self.hit_map_dirty = true; if any_reused || self.line_chunk_cache.is_empty() { self.styled_redraw_deadline = None; self.window.request_redraw(); } else { // Far jump (Q#M6, bet #2): every line rebuilt, and the // span set covers the *old* viewport — drawing now would // flash unstyled text. Hold the redraw until the restyle // lands (`refresh_changed_lines` clears this) or the // deadline fires in `about_to_wait`. self.styled_redraw_deadline = Some(std::time::Instant::now() + JUMP_STYLE_HOLD); } } /// Re-shape ONLY lines whose chunk set changed — the incoming /// frame path (`StyleSpans` / fg `Decorations` / `InlineAdornments`). /// A parse-settle frame after a typing burst usually recolors a /// line or two; re-shaping the whole slice for it was a full /// keystroke-cost stall. fn refresh_changed_lines(&mut self) { let (vstart, vend) = self.visible_byte_range(); let (top, ranges) = self.slice_line_ranges(vstart, vend); if (vstart, vend) != self.view_range || top != self.shaped_top || ranges.len() != self.line_chunk_cache.len() || ranges.len() != self.buffer.lines.len() { self.reshape(); return; } let mut any = false; for (i, &(ls, ce)) in ranges.iter().enumerate() { let chunks = self.chunks_for_line(ls, ce); if chunks != self.line_chunk_cache[i] { self.buffer.lines[i] = line_from_chunks(&chunks); self.line_chunk_cache[i] = chunks; any = true; } } if any { self.buffer.shape_until_scroll(&mut self.font_system, false); self.hit_map_dirty = true; } // Fresh styling reached the slice — release any held // post-jump frame (Q#M6, bet #2). self.styled_redraw_deadline = None; self.window.request_redraw(); } /// Compose the status-band readout (Q#S1): diagnostic counts /// (wire-authoritative, severity-colored, omitted when zero), /// then cursor L:C from the *optimistic* caret (so it tracks /// typing bursts instead of lagging a round trip), then the /// All/Top/Bot/NN% scroll indicator. Returns the colored spans. fn compose_status_spans(&self) -> Vec<(String, Option)> { use std::fmt::Write as _; let mut spans: Vec<(String, Option)> = Vec::new(); if let Some(facts) = self .status_facts .as_ref() .filter(|f| Some(f.buffer_id) == self.current_buffer_id) { if facts.diag_errors > 0 { spans.push(( format!("E:{}", facts.diag_errors), Some(Color::rgb(241, 76, 76)), )); } if facts.diag_warnings > 0 { spans.push(( format!("W:{}", facts.diag_warnings), Some(Color::rgb(245, 245, 67)), )); } } let mut readout = String::new(); let mut cursor_row = self.scroll_top; if let Some(own) = self.own_cursor && self.current_buffer_id == Some(own.buffer_id) { let byte = floor_char_boundary( &self.current_text, (own.byte as usize).min(self.current_text.len()), ); let line = self .current_line_starts .partition_point(|&s| s as usize <= byte) .saturating_sub(1); cursor_row = line; let ls = self.current_line_starts.get(line).copied().unwrap_or(0) as usize; let col = self .current_text .get(ls..byte) .map_or(0, |s| s.chars().count()); let _ = write!(readout, "L{}:C{}", line + 1, col + 1); readout.push_str(" "); } readout.push_str(&format_scroll_indicator( self.scroll_top, estimated_visible_lines(self.config.height), self.current_line_starts.len(), cursor_row, )); spans.push((readout, None)); spans } /// The band's left side. While an incremental search is running /// (Q#SR5) it shows `I-search: (n/m)` — the prompt takes /// over the band like Emacs's echo area, returning to the buffer /// name + modified dot (v8 `StatusFacts`) when the search ends. fn compose_status_left(&self) -> String { // Q#MB1 — an open minibuffer takes over the band: prompt + input // (the candidates render separately as a dropdown). Measured by // the band caret, so it must stay exactly `prompt + input`. if let Some(mb) = self.minibuffer.as_ref() { return format!("{}{}", mb.prompt, mb.input); } if let Some(sp) = self .search_prompt .as_ref() .filter(|s| Some(s.buffer_id) == self.current_buffer_id) { let label = if sp.regex { "Regex I-search: " } else { "I-search: " }; let count = if sp.query.is_empty() { String::new() } else if sp.invalid { " [invalid]".to_string() } else if sp.total == 0 { " [no match]".to_string() } else { format!(" ({}/{})", sp.active.map_or(0, |a| a + 1), sp.total) }; return format!("{}{}{}", label, sp.query, count); } match self .status_facts .as_ref() .filter(|f| Some(f.buffer_id) == self.current_buffer_id) { Some(facts) if facts.modified => format!("{} ●", facts.name), Some(facts) => facts.name.clone(), None => String::new(), } } /// Re-shape the status-band text iff the composed content /// changed (short lines — shaping is trivial, but not free per /// frame). fn refresh_status_line(&mut self) { let spans = self.compose_status_spans(); let composed: String = spans .iter() .map(|(t, _)| t.as_str()) .collect::>() .join(" "); let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono")); if composed != self.status_text { let mut rich: Vec<(&str, Attrs)> = Vec::new(); for (i, (t, c)) in spans.iter().enumerate() { if i > 0 { rich.push((" ", default_attrs.clone())); } let attrs = match c { Some(color) => default_attrs.clone().color(*color), None => default_attrs.clone(), }; rich.push((t.as_str(), attrs)); } self.status_buffer.set_rich_text( &mut self.font_system, rich, &default_attrs, Shaping::Advanced, None, ); self.status_buffer .shape_until_scroll(&mut self.font_system, false); self.status_text = composed; } let left = self.compose_status_left(); if left != self.status_left_text { self.status_left_buffer.set_text( &mut self.font_system, &left, &default_attrs, Shaping::Advanced, None, ); self.status_left_buffer .shape_until_scroll(&mut self.font_system, false); self.status_left_text = left; } } /// The status band's background quad (Q#S2): a full-width strip /// under the band text. fn status_band_vertex_bytes(&self) -> Vec { let rect = MinimapRect { x: 0.0, y: text_area_bottom(self.config.height), w: self.config.width as f32, h: STATUS_BAND_HEIGHT, color: STATUS_BAND_BG, }; rects_to_vertex_bytes(&[rect], self.config.width, self.config.height) } /// Re-shape the menu label text from `self.menu` (Q#CM1), one line /// per row (separators are blank lines so rows stay aligned with the /// bg quads). A no-op string when the menu is closed. fn refresh_menu_buffer(&mut self) { let text = self.menu.as_ref().map_or_else(String::new, |menu| { menu.rows .iter() .map(|r| if r.separator { "" } else { r.label.as_str() }) .collect::>() .join("\n") }); self.menu_buffer.set_text( &mut self.font_system, &text, &Attrs::new().family(Family::Name("JetBrains Mono")), Shaping::Advanced, None, ); self.menu_buffer .shape_until_scroll(&mut self.font_system, false); } /// Popup background, active-row highlight, and separator quads /// (Q#CM1). Empty when the menu is closed. fn menu_vertex_bytes(&self) -> Vec { let Some(menu) = self.menu.as_ref() else { return Vec::new(); }; let ax = menu.anchor_px.0 as f32; let ay = menu.anchor_px.1 as f32; let w = Self::menu_width_px(menu); let mut rects = vec![MinimapRect { x: ax, y: ay, w, h: menu.rows.len() as f32 * MENU_ROW_HEIGHT, color: MENU_BG, }]; for (i, row) in menu.rows.iter().enumerate() { let ry = ay + i as f32 * MENU_ROW_HEIGHT; if row.separator { rects.push(MinimapRect { x: ax + MENU_PAD_X, y: ry + MENU_ROW_HEIGHT / 2.0 - 0.5, w: w - 2.0 * MENU_PAD_X, h: 1.0, color: MENU_SEPARATOR_BG, }); } else if menu.active == Some(i as u32) { rects.push(MinimapRect { x: ax, y: ry, w, h: MENU_ROW_HEIGHT, color: MENU_SELECTED_BG, }); } } rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } /// Re-shape the minibuffer dropdown candidates (Q#MB1), one line per /// candidate, best match first. Empty when there are no candidates. fn refresh_mb_buffer(&mut self) { let text = self .minibuffer .as_ref() .map_or_else(String::new, |mb| mb.candidates.join("\n")); self.mb_buffer.set_text( &mut self.font_system, &text, &Attrs::new().family(Family::Name("JetBrains Mono")), Shaping::Advanced, None, ); self.mb_buffer .shape_until_scroll(&mut self.font_system, false); } /// Dropdown geometry `(left, top_y, width)` when the minibuffer has /// candidates: a list anchored just above the bottom band, growing /// upward, as wide as the widest candidate (clamped). `None` when /// closed or candidate-free. `refresh_mb_buffer` must have run so the /// width measurement is current. fn mb_dropdown_rect(&self) -> Option<(f32, f32, f32)> { let mb = self.minibuffer.as_ref()?; let n = mb.candidates.len(); if n == 0 { return None; } let widest = self .mb_buffer .layout_runs() .map(|r| r.line_w) .fold(0.0_f32, f32::max); let width = (widest + 2.0 * MB_DROP_PAD_X).clamp(MB_DROP_MIN_WIDTH, MB_DROP_MAX_WIDTH); let band_top = text_area_bottom(self.config.height); let top_y = band_top - n as f32 * MB_DROP_ROW_HEIGHT; Some((STATUS_TEXT_PAD, top_y, width)) } /// Minibuffer dropdown background + selection-highlight quads (Q#MB1). /// Empty when closed / candidate-free. fn mb_dropdown_vertex_bytes(&self) -> Vec { let Some(mb) = self.minibuffer.as_ref() else { return Vec::new(); }; let Some((x, top_y, width)) = self.mb_dropdown_rect() else { return Vec::new(); }; let n = mb.candidates.len(); let mut rects = vec![MinimapRect { x, y: top_y, w: width, h: n as f32 * MB_DROP_ROW_HEIGHT, color: MENU_BG, }]; if let Some(sel) = mb.selected { rects.push(MinimapRect { x, y: top_y + sel as f32 * MB_DROP_ROW_HEIGHT, w: width, h: MB_DROP_ROW_HEIGHT, color: MENU_SELECTED_BG, }); } rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } /// Bookkeeping for an outgoing Pointer event: it supersedes any /// unconfirmed optimistic-cursor prediction (the daemon's answer /// will be the click position, not the typing prediction), and /// the cursor is not authoritative again until that `CursorByte` /// lands. fn note_pointer_round_trip(&mut self) { self.cursor_fresh = false; self.optimistic_cursor_floor = None; self.optimistic_floor_set_at = None; } /// Frontend-side multi-click detection: a second Down at the /// same hit byte within the interval upgrades to `DoubleDown`, /// a third to `TripleDown` (Q#M4); a fourth restarts the chain. fn classify_pointer_down(&mut self, byte: u64, shift: bool) -> PointerKind { if shift { // Shift-click extends the selection (Q#M5); it neither // advances nor inherits the multi-click chain — two // Shift-clicks must not become a word select. self.last_pointer_down = None; return PointerKind::Down; } let now = std::time::Instant::now(); let prior_chain = self .last_pointer_down .take() .and_then(|(at, prev, count)| { (prev == byte && now.duration_since(at) <= DOUBLE_CLICK_WINDOW).then_some(count) }) .unwrap_or(0); match prior_chain { 0 => { self.last_pointer_down = Some((now, byte, 1)); PointerKind::Down } 1 => { self.last_pointer_down = Some((now, byte, 2)); PointerKind::DoubleDown } _ => { // Chain consumed: a fourth click starts over. PointerKind::TripleDown } } } fn apply_file_style_summary( &mut self, buffer_id: BufferId, generation: u64, lines: Vec, ) { if self.current_buffer_id != Some(buffer_id) { return; } if self .current_summary .as_ref() .is_some_and(|summary| generation < summary.generation) { return; } self.current_line_shapes = minimap_line_shapes(&self.current_text); self.current_summary = Some(FileStyleSummaryState { generation, lines }); self.window.request_redraw(); } /// `full = true` path: discard prior styling, take the segments' /// spans as authoritative for the declared viewport. fn replace_style_spans(&mut self, segments: Vec) { self.current_spans.clear(); for seg in segments { self.current_spans.extend(seg.spans); } self.current_spans.sort_by_key(|s| s.range.start); } /// `full = false` path: each segment's `range` authoritatively /// replaces styling within it. Spans fully inside any dirty range /// drop; spans straddling a dirty edge get clipped to outside the /// range; the new spans are appended; finally everything sorts. /// /// This is exactly the surface bet #1 from the framing pass /// predicted ("dirty-segment edges at viewport boundaries — /// headless-test-blind-spot probe"). Per-byte adversarial /// behavior here lives in the user-side validation, not in unit /// tests — that's the design-doc framing's whole point. fn merge_style_spans(&mut self, segments: Vec) { for seg in &segments { let dirty = seg.range; let mut kept = Vec::with_capacity(self.current_spans.len()); for sp in self.current_spans.drain(..) { if sp.range.end <= dirty.start || sp.range.start >= dirty.end { // Outside the dirty range entirely — keep as-is. kept.push(sp); } else if sp.range.start < dirty.start && sp.range.end > dirty.end { // Straddles both edges: split into two clipped halves. kept.push(StyleSpan { range: ByteRange { start: sp.range.start, end: dirty.start, }, style: sp.style, }); kept.push(StyleSpan { range: ByteRange { start: dirty.end, end: sp.range.end, }, style: sp.style, }); } else if sp.range.start < dirty.start { // Straddles the left edge only — clip to the left. kept.push(StyleSpan { range: ByteRange { start: sp.range.start, end: dirty.start, }, style: sp.style, }); } else if sp.range.end > dirty.end { // Straddles the right edge only — clip to the right. kept.push(StyleSpan { range: ByteRange { start: dirty.end, end: sp.range.end, }, style: sp.style, }); } // else: fully inside the dirty range ⇒ drop. } self.current_spans = kept; } for seg in segments { self.current_spans.extend(seg.spans); } self.current_spans.sort_by_key(|s| s.range.start); } /// `Decorations { full: true, .. }` path — exactly the /// `replace_style_spans` shape for decorations. The wire structure /// is intentionally symmetric (`DecorationSegment` ↔ `StyleSegment`). fn replace_decorations(&mut self, segments: Vec) { self.current_decorations.clear(); for seg in segments { self.current_decorations.extend(seg.decorations); } self.current_decorations.sort_by_key(|d| d.range.start); } /// `Decorations { full: false, .. }` path — M11.4 dirty-merge for /// decorations. Structurally identical to [`Self::merge_style_spans`] /// — same edge-clip/drop/split logic, same trailing append + /// re-sort. /// /// **Recorded session-5 finding (rule iii, deferred):** this /// duplication of the M11.4 merge algorithm across two /// `(range, T)`-shaped types invites a generic /// `merge_dirty_segments` helper. The refactor is /// minor in lines but touches a load-bearing invariant; deferring /// until at least a third instance arrives (e.g. peer-cursor /// decorations from `PresenceUpdate`) so the abstraction is /// inducted from three points rather than two. fn merge_decorations(&mut self, segments: Vec) { for seg in &segments { let dirty = seg.range; let mut kept = Vec::with_capacity(self.current_decorations.len()); for d in self.current_decorations.drain(..) { if d.range.end <= dirty.start || d.range.start >= dirty.end { kept.push(d); } else if d.range.start < dirty.start && d.range.end > dirty.end { kept.push(Decoration { range: ByteRange { start: d.range.start, end: dirty.start, }, kind: d.kind, }); kept.push(Decoration { range: ByteRange { start: dirty.end, end: d.range.end, }, kind: d.kind, }); } else if d.range.start < dirty.start { kept.push(Decoration { range: ByteRange { start: d.range.start, end: dirty.start, }, kind: d.kind, }); } else if d.range.end > dirty.end { kept.push(Decoration { range: ByteRange { start: dirty.end, end: d.range.end, }, kind: d.kind, }); } } self.current_decorations = kept; } for seg in segments { self.current_decorations.extend(seg.decorations); } self.current_decorations.sort_by_key(|d| d.range.start); } /// Re-build the cosmic-text Buffer from `current_text` + /// `current_spans` + `current_decorations` + /// `current_adornments`. Source styling/decorations remain /// byte-indexed into `current_text`; adornments contribute extra /// rich-text chunks at their anchors without mutating the source /// string. That display projection is the central session-6 /// invariant: virtual text must not shift the source-byte ranges /// used by `StyleSpans` / `Decorations`. /// /// Complexity is O(B × (S + D)) per reshape where B is the boundary /// count and S+D is spans+decorations. For viewport-scoped data /// this is bounded by visible bytes. A sweep-line refactor with /// active-set pointers is the obvious upgrade if reshape cost /// surfaces in profile data — recorded but not done in session 5. /// Whole-file byte range `[vstart, vend)` of the source lines that /// should be shaped: from `scroll_top` through the visible window /// plus a small overscan (Q#S1/S3). Both ends fall on line /// boundaries (cosmic-text splits `BufferLine`s on `\n`, so a /// mid-line slice would corrupt the first/last line). fn visible_byte_range(&self) -> (u64, u64) { let line_starts = &self.current_line_starts; let n = line_starts.len(); let top = self.scroll_top.min(n.saturating_sub(1)); let span = estimated_visible_lines(self.config.height).max(1) + SCROLL_OVERSCAN; let vstart = line_starts[top]; let bottom = top.saturating_add(span).min(n); let vend = if bottom < n { line_starts[bottom] } else { self.current_text.len() as u64 }; (vstart, vend) } fn reshape(&mut self) { // Session S1 — shape only the visible byte slice. Feeding the // whole rope to `set_rich_text` (a BufferLine per source line) // made large-file editing O(file) per keystroke; cosmic-text // touches only `current_text[vstart..vend]` now. Spans / // decorations / adornments arrive in whole-file coordinates and // are clipped + rebased onto the slice (subtract `vstart`). let (vstart, vend) = self.visible_byte_range(); self.view_range = (vstart, vend); let (top, ranges) = self.slice_line_ranges(vstart, vend); let mut lines = Vec::with_capacity(ranges.len()); let mut cache = Vec::with_capacity(ranges.len()); for &(ls, ce) in &ranges { let chunks = self.chunks_for_line(ls, ce); lines.push(line_from_chunks(&chunks)); cache.push(chunks); } self.buffer.lines = lines; self.line_chunk_cache = cache; self.shaped_top = top; self.buffer .set_scroll(glyphon::cosmic_text::Scroll::default()); self.buffer.shape_until_scroll(&mut self.font_system, false); // The pointer hit map rebuilds lazily from the same caches // (Q#R2) — clicks are rare next to keystrokes/frames. self.hit_map_dirty = true; // Full restyle: release any held post-jump frame (Q#M6). self.styled_redraw_deadline = None; self.window.request_redraw(); } fn resize(&mut self, width: u32, height: u32) -> Option { self.config.width = width; self.config.height = height; self.surface.configure(&self.device, &self.config); self.viewport .update(&self.queue, Resolution { width, height }); self.buffer.set_size( &mut self.font_system, Some(width as f32), Some(height as f32), ); self.status_buffer.set_size( &mut self.font_system, Some(width as f32), Some(STATUS_BAND_HEIGHT), ); self.status_left_buffer.set_size( &mut self.font_system, Some(width as f32), Some(STATUS_BAND_HEIGHT), ); // 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. fn render(&mut self) { let frame = match self.surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(frame) | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { self.surface.configure(&self.device, &self.config); return; } wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => return, wgpu::CurrentSurfaceTexture::Validation => { eprintln!("surface acquisition raised a validation error"); return; } }; let view = frame .texture .create_view(&wgpu::TextureViewDescriptor::default()); let frame_start = debug_frame().then(std::time::Instant::now); self.refresh_status_line(); self.refresh_menu_buffer(); // Q#CM1 — the context-menu popup quads (bg / highlight / // separators), drawn as a top layer after everything else. let menu_vertices = self.menu_vertex_bytes(); let menu_vertex_count = (menu_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let menu_bg_buffer = self .menu_bg_vertex_buffer .upload( &self.device, &self.queue, "pmacs-gpu context menu", &menu_vertices, ) .cloned(); // Q#MB1 — the minibuffer dropdown quads (bg + selection), a top // layer above the band. `refresh_mb_buffer` first so the width // measurement in `mb_dropdown_vertex_bytes` is current. self.refresh_mb_buffer(); let mb_vertices = self.mb_dropdown_vertex_bytes(); let mb_vertex_count = (mb_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let mb_bg_buffer = self .mb_bg_vertex_buffer .upload( &self.device, &self.queue, "pmacs-gpu minibuffer dropdown", &mb_vertices, ) .cloned(); // The band's strip rides the bg quad batch so it draws under // the band text (text renders after the first quad draw). let mut bg_vertices = self.decoration_background_vertex_bytes(); bg_vertices.extend(self.status_band_vertex_bytes()); let bg_vertex_count = (bg_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let bg_buffer = self .bg_vertex_buffer .upload( &self.device, &self.queue, "pmacs-gpu decoration backgrounds", &bg_vertices, ) .cloned(); // Diagnostic squiggles (Q#W1): own pipeline + buffer, drawn // between the wash quads and the text (under the glyphs, the // z-slot the straight bar held). let squiggle_vertices = self.squiggle_vertex_bytes(); let squiggle_vertex_count = (squiggle_vertices.len() / SQUIGGLE_VERTEX_STRIDE as usize) as u32; let squiggle_buffer = self .squiggle_vertex_buffer .upload( &self.device, &self.queue, "pmacs-gpu diagnostic squiggles", &squiggle_vertices, ) .cloned(); let caret_vertices = self.caret_vertex_bytes(); let caret_vertex_count = (caret_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let caret_buffer = self .caret_vertex_buffer .upload( &self.device, &self.queue, "pmacs-gpu caret", &caret_vertices, ) .cloned(); let after_bg = debug_frame().then(std::time::Instant::now); // Minimap quads depend only on (summary, size, scroll); cache // the vertex bytes instead of rescanning every line shape per // frame. let minimap_key = ( self.current_summary.as_ref().map_or(0, |s| s.generation), self.config.width, self.config.height, self.scroll_top, ); if self .minimap_cache .as_ref() .is_none_or(|(key, _)| *key != minimap_key) { self.minimap_cache = Some((minimap_key, self.minimap_vertex_bytes())); } let minimap_vertices = &self.minimap_cache.as_ref().expect("just filled").1; let minimap_vertex_count = (minimap_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let minimap_buffer = self .minimap_vertex_buffer .upload( &self.device, &self.queue, "pmacs-gpu minimap vertices", minimap_vertices, ) .cloned(); let after_minimap = debug_frame().then(std::time::Instant::now); let text_bounds_right = self.text_bounds_right(); // Right-align the status readout: measure the shaped width // and place the area flush to the right pad (Q#S2). let status_width = self .status_buffer .layout_runs() .map(|r| r.line_w) .fold(0.0_f32, f32::max); let status_left = (self.config.width as f32 - STATUS_TEXT_PAD - status_width).max(TEXT_LEFT); let status_top = text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0; self.text_renderer .prepare( &self.device, &self.queue, &mut self.font_system, &mut self.atlas, &self.viewport, [ TextArea { buffer: &self.buffer, left: TEXT_LEFT, top: TEXT_TOP, scale: 1.0, bounds: TextBounds { left: 0, top: 0, right: text_bounds_right, // Clip at the status band (Q#S3): a final // partially-visible line must not bleed // into the band. bottom: text_area_bottom(self.config.height).round() as i32, }, default_color: Color::rgb(230, 230, 235), custom_glyphs: &[], }, TextArea { buffer: &self.status_buffer, left: status_left, top: status_top, scale: 1.0, bounds: TextBounds { left: 0, top: text_area_bottom(self.config.height).round() as i32, right: self.config.width.cast_signed(), bottom: self.config.height.cast_signed(), }, default_color: Color::rgb(168, 168, 180), custom_glyphs: &[], }, TextArea { buffer: &self.status_left_buffer, left: STATUS_TEXT_PAD, top: status_top, scale: 1.0, bounds: TextBounds { left: 0, top: text_area_bottom(self.config.height).round() as i32, // Stop before the right-aligned readout. right: (status_left - STATUS_TEXT_PAD).max(0.0).round() as i32, bottom: self.config.height.cast_signed(), }, default_color: Color::rgb(200, 200, 210), custom_glyphs: &[], }, ], &mut self.swash_cache, ) .expect("text_renderer prepare"); // Q#CM1 — prepare the menu glyphs in their own layer (empty when // closed, so the renderer draws nothing). let menu_areas: Vec