//! 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; mod terminal; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use glyphon::cosmic_text::{Affinity, Cursor, Scroll, Wrap}; use glyphon::{ Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, fontdb, }; use loro::{ContainerTrait, ExportMode}; use pmacs_protocol::{ AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CellCoord, CellSize, CompletionPopupRow, CrdtOp, Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, InstanceSignal, Key as ProtocolKey, LineNumberMode, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers, MouseButton as ProtocolMouseButton, MouseKind as ProtocolMouseKind, PointerKind, SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame, UnderlineStyle, cell::{Color as CellColor, Style as CellStyle}, is_builtin_pair_char, is_modeline_face_name, }; use unicode_width::UnicodeWidthChar; 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}; use crate::terminal::{TerminalPaintPlan, TerminalPalette}; /// Bundled font (SIL Open Font License 1.1 — see `fonts/OFL.txt`). const JETBRAINS_MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono-Regular.ttf"); #[cfg(test)] const TEST_MONO_TWO: &[u8] = include_bytes!("../fonts/test/PmacsTestMonoTwo-Regular.ttf"); #[cfg(test)] const TEST_PROPORTIONAL: &[u8] = include_bytes!("../fonts/test/PmacsTestProportional-Regular.ttf"); #[cfg(test)] const TEST_FAMILY_REGULAR: &[u8] = include_bytes!("../fonts/test/PmacsTestFamily-Regular.ttf"); #[cfg(test)] const TEST_FAMILY_BOLD: &[u8] = include_bytes!("../fonts/test/PmacsTestFamily-Bold.ttf"); #[cfg(test)] const TEST_FONT_SOURCES: &[&[u8]] = &[ TEST_MONO_TWO, TEST_PROPORTIONAL, TEST_FAMILY_REGULAR, TEST_FAMILY_BOLD, ]; /// Extra font sources a headless `State` assembles with. Test builds add /// the fixture families the font-preference tests rely on; the Vterm /// Stage 3 attach probe, which is a release-mode binary, gets none. fn headless_extra_font_sources() -> &'static [&'static [u8]] { #[cfg(test)] { TEST_FONT_SOURCES } #[cfg(not(test))] { &[] } } /// The default font family — the query `family: None` (and every /// rejected requested family) resolves through (framing Q#F6). The /// bundle guarantees the query is never empty; a monospaced /// system-installed face of the same family may legitimately win by /// insertion order (availability, not face identity). const DEFAULT_FONT_FAMILY: &str = "JetBrains Mono"; /// Derived per-preference metrics (framing Q#F6). One knob — the /// preference size — scales every surface by `size / 16.0`; the /// unset default (`scale == 1.0`, `advance_ratio == 1.0`) /// reproduces today's `BASE_*` constants bit-for-bit, so never-set /// renders byte-identically. `advance_ratio` is the measured /// selected/default NORMAL-face advance ratio (the fixed-ASCII /// probe): the empty-document gutter fallback and the menu hit /// width follow the resolved family without JetBrains-only drift. #[derive(Clone, Copy)] struct FontMetrics { scale: f32, advance_ratio: f32, } impl Default for FontMetrics { fn default() -> Self { Self { scale: 1.0, advance_ratio: 1.0, } } } impl FontMetrics { fn code_font_size(self) -> f32 { BASE_CODE_FONT_SIZE * self.scale } fn code_line_height(self) -> f32 { BASE_CODE_LINE_HEIGHT * self.scale } fn gutter_advance_fallback(self) -> f32 { BASE_GUTTER_MONO_ADVANCE_FALLBACK * self.scale * self.advance_ratio } fn status_band_height(self) -> f32 { BASE_STATUS_BAND_HEIGHT * self.scale } fn status_font_size(self) -> f32 { BASE_STATUS_FONT_SIZE * self.scale } fn status_line_height(self) -> f32 { BASE_STATUS_LINE_HEIGHT * self.scale } fn menu_row_height(self) -> f32 { BASE_MENU_ROW_HEIGHT * self.scale } fn menu_font_size(self) -> f32 { BASE_MENU_FONT_SIZE * self.scale } fn menu_line_height(self) -> f32 { BASE_MENU_LINE_HEIGHT * self.scale } fn menu_char_w(self) -> f32 { BASE_MENU_CHAR_W * self.scale * self.advance_ratio } fn mb_drop_row_height(self) -> f32 { BASE_MB_DROP_ROW_HEIGHT * self.scale } fn mb_drop_font_size(self) -> f32 { BASE_MB_DROP_FONT_SIZE * self.scale } fn mb_drop_line_height(self) -> f32 { BASE_MB_DROP_LINE_HEIGHT * self.scale } } /// What sanitized assembly retained (framing Q#F6): the default /// family name and the bundled face's ID, both asserted present and /// monospaced at assembly time. The rejected-family fallback and the /// `family: None` default both resolve through /// `Family::Name(&default_family)` against the sanitized db, so the /// fallback is total and cannot recurse into a proportional /// collision. struct FontDefaults { default_family: String, bundled_id: fontdb::ID, /// IDs loaded through the optional assembly input. Production /// passes no extras; headless tests retain fixture IDs so they can /// assert cosmic-text classified the final database, not a /// post-construction mutation. #[cfg(test)] extra_ids: Vec, } /// Remove every NON-monospace face that advertises `default_family` /// (framing Q#F6, round 3 finding 4): fontdb returns the first /// surviving equally-good candidate in insertion order, so a /// closer-weight proportional system face could otherwise win /// bold/italic queries even when the normal query selected a valid /// monospaced face. Parameterized by family + bundled ID so tests /// run the PRODUCTION path with an unreserved fixture family. The /// bundled face is monospaced and survives by construction. fn sanitize_font_database(db: &mut fontdb::Database, default_family: &str, bundled_id: fontdb::ID) { let doomed: Vec = db .faces() .filter(|f| { !f.monospaced && f.id != bundled_id && f.families.iter().any(|(name, _)| name == default_family) }) .map(|f| f.id) .collect(); for id in doomed { db.remove_face(id); } } /// Sanitized, current-order font database + `FontSystem` (framing /// Q#F6): system fonts FIRST (what `FontSystem::new()` does today), /// the bundled bytes second — retaining the bundled `fontdb::ID` — /// then the collision filter, then cosmic-text's current /// generic-family defaults, and only then the `FontSystem` /// construction, so its internal monospace-ID set includes every /// surviving monospaced face (the bundle included). The locale is /// resolved exactly as cosmic-text does (`sys_locale`, `"en-US"` /// fallback). fn build_font_system(extra_sources: &[&'static [u8]]) -> (FontSystem, FontDefaults) { let mut db = fontdb::Database::new(); db.load_system_fonts(); let bundled_ids = db.load_font_source(fontdb::Source::Binary(std::sync::Arc::new(JETBRAINS_MONO))); let bundled_id = *bundled_ids .first() .expect("bundled JetBrains Mono contains one face"); let extra_ids: Vec = extra_sources .iter() .flat_map(|bytes| db.load_font_source(fontdb::Source::Binary(std::sync::Arc::new(*bytes)))) .collect(); sanitize_font_database(&mut db, DEFAULT_FONT_FAMILY, bundled_id); db.set_monospace_family("Noto Sans Mono"); db.set_sans_serif_family("Open Sans"); db.set_serif_family("DejaVu Serif"); let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")); let font_system = FontSystem::new_with_locale_and_db(locale, db); #[cfg(not(test))] drop(extra_ids); let defaults = FontDefaults { default_family: DEFAULT_FONT_FAMILY.to_owned(), bundled_id, #[cfg(test)] extra_ids, }; // Assembly-time assertions (framing Q#F6): the default query and // the bundled face are present and monospaced — the total // fallback depends on both. debug_assert!( font_system .db() .face(defaults.bundled_id) .is_some_and(|f| f.monospaced), "the bundled face must survive sanitization and be monospaced" ); debug_assert!( font_system.is_monospace(defaults.bundled_id), "cosmic-text must register the bundled face as monospace" ); debug_assert!( query_normal_face(font_system.db(), &defaults.default_family) .and_then(|id| font_system.db().face(id)) .is_some_and(|f| f.monospaced), "the default family query must resolve to a monospaced face" ); (font_system, defaults) } /// The normal-style query for `family` — the same `fontdb::Query` /// implied by the base `Attrs` installed on all seven buffers /// (normal weight, normal style, normal stretch). fn query_normal_face(db: &fontdb::Database, family: &str) -> Option { db.query(&fontdb::Query { families: &[fontdb::Family::Name(family)], weight: fontdb::Weight::NORMAL, stretch: fontdb::Stretch::Normal, style: fontdb::Style::Normal, }) } /// The fixed ASCII advance probe (framing Q#F6). The measurement uses /// its total shaped width divided by this logical cell count; it does /// not assume one glyph per digit because a valid monospace face may /// substitute multi-cell digit ligatures. const ADVANCE_PROBE: &str = "0123456789"; /// Wire-validation bounds for `FontFacts::size_centi_px` — 6.0..=72.0 /// logical px in integer hundredths (framing Q#F6, fail closed): 0 /// would panic `Buffer::set_metrics`, and the GPU re-checks on /// arrival because this is deserialized protocol input — the /// daemon-side Lua range check is a UX courtesy, not a trust /// boundary. const FONT_SIZE_CENTI_PX_RANGE: std::ops::RangeInclusive = 600..=7200; /// Measure `family`'s normal-face cell advance at `metrics` by /// shaping [`ADVANCE_PROBE`] in a scratch buffer — independent of /// document contents, so the measurement is deterministic and the /// NORMAL face is authoritative even when the first code glyph is /// bold/italic. Total run width divided by logical cells survives /// ligature substitution; `None` when the family shapes no width. fn probe_mono_advance(font_system: &mut FontSystem, family: &str, metrics: Metrics) -> Option { let mut probe = Buffer::new(font_system, metrics); probe.set_size(font_system, None, None); probe.set_text( font_system, ADVANCE_PROBE, &Attrs::new().family(Family::Name(family)), Shaping::Advanced, None, ); probe.shape_until_scroll(font_system, false); let total_width: f32 = probe.layout_runs().map(|run| run.line_w).sum(); let cells = ADVANCE_PROBE.chars().count() as f32; (total_width > 0.0 && cells > 0.0).then_some(total_width / cells) } /// 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; /// Stroke thickness for terminal straight-underline forms, in pixels. const TERMINAL_UNDERLINE_PX: f32 = 1.0; /// Fallback terminal selection wash when no `ui.selection` face is set. const TERMINAL_SELECTION_RGBA: [f32; 4] = [0.35, 0.45, 0.75, 0.35]; /// The terminal cursor block. Translucent so the glyph beneath stays /// readable — a terminal cursor sits ON a character, unlike the /// document caret, which sits between two. const TERMINAL_CURSOR_RGBA: [f32; 4] = [0.85, 0.85, 0.9, 0.55]; /// 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 BASE_CODE_LINE_HEIGHT: f32 = 22.0; /// Font size of the code buffer (and the line-number gutter, so their /// line heights match and rows align). const BASE_CODE_FONT_SIZE: f32 = 16.0; /// Gap in px between the line-number gutter digits and the code /// (UX gutter arc, GPU side of sub-arc 1 — mirrors the TUI gutter). const GUTTER_GAP_PX: f32 = 10.0; /// Fallback monospace advance in px when no shaped glyph is available to /// measure (0.6 em at the 16px code font). const BASE_GUTTER_MONO_ADVANCE_FALLBACK: f32 = 9.6; /// Diagnostic gutter sign (UX gutter sub-arc 2): a thin severity-colored /// bar hugging the gutter's left edge, left of the line numbers — the GPU /// analogue of the TUI's leading-column sign glyph. `X` is its left inset, /// `W` its width; it spans the full line height. const GUTTER_SIGN_X: f32 = 4.0; const GUTTER_SIGN_W: f32 = 4.0; /// Minimum text-area width (px) the gutter must leave. If reserving the /// gutter would crowd the text below this, the gutter is dropped for the /// frame — the GPU mirror of the TUI's too-narrow-window disable, so a /// narrow window or a very large file can never force `left >= right`. const MIN_TEXT_WIDTH_PX: f32 = 48.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 BASE_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 BASE_STATUS_FONT_SIZE: f32 = 13.0; const BASE_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 BASE_MENU_ROW_HEIGHT: f32 = 22.0; const BASE_MENU_FONT_SIZE: f32 = 14.0; const BASE_MENU_LINE_HEIGHT: f32 = 22.0; const MENU_PAD_X: f32 = 12.0; const BASE_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 BASE_MB_DROP_ROW_HEIGHT: f32 = 20.0; const BASE_MB_DROP_FONT_SIZE: f32 = 13.0; const BASE_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; /// Visible slice of the completion dropdown given `n` shaped candidates, /// the `selected` index, and `band_top` pixels available above the status /// band (audit F-007). Returns `(first, count)` — `count` clamped to the /// rows that actually fit (so the box never renders above `y = 0`) and /// `first` scrolled to keep `selected` on screen. `None` when nothing can /// show: no candidates, or the window is too short for even one row. When /// the whole list fits this is `(0, n)`, identical to the pre-clamp /// behavior — the common path is unchanged. fn mb_dropdown_window( n: usize, selected: usize, band_top: f32, fm: FontMetrics, ) -> Option<(usize, usize)> { if n == 0 { return None; } let max_rows = (band_top / fm.mb_drop_row_height()).floor() as usize; if max_rows == 0 { return None; } let count = n.min(max_rows); let sel = selected.min(n - 1); // Anchor `sel` at the window's bottom edge when it would otherwise be // below the fold, then clamp so we never scroll past the last row. let first = sel.saturating_sub(count - 1).min(n - count); Some((first, count)) } 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 }, /// `pmacs-gpu --headless-probe `: attach through /// the real client, render real frames offscreen, and write a /// machine-readable report. /// /// This exists for the Vterm Stage 3 acceptance, which must exercise /// a real daemon, a real PTY, and real wgpu rendering in ONE path. /// It drives the same `attach` handshake, the same /// `apply_attach_message`, and the same `render_to_view` the windowed /// mode does — only winit is absent, because CI has no display. HeadlessProbe { socket: PathBuf, report: PathBuf }, } /// Number of decimal digits in `n` (for `n >= 1`); allocation-free. Sizes /// the line-number gutter (UX gutter arc). Mirrors the TUI's /// `pmacs::window::decimal_digits` — kept local since pmacs-gpu doesn't /// depend on the `pmacs` crate. fn decimal_digits(mut n: usize) -> u32 { let mut d = 1u32; while n >= 10 { n /= 10; d += 1; } d } fn main() { env_logger::init(); let mode = parse_args(std::env::args().skip(1).collect()); if let Mode::HeadlessProbe { socket, report } = &mode { std::process::exit(run_headless_probe(socket, report)); } 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"); } /// Drive a real attach session headlessly and write a probe report. /// /// The Vterm Stage 3 acceptance needs one path that exercises a real /// daemon, a real PTY child, and real wgpu rendering together — a /// decoded-message fixture would prove none of the three fit. This is /// that path minus winit, which CI has no display for. /// /// The report is one `key=value` line per fact so the acceptance test /// asserts on named observations rather than parsing prose. Exit code 0 /// means the report was written; anything else means the probe could not /// run and the acceptance fails loudly rather than reading a stale file. #[allow( clippy::too_many_lines, reason = "one linear attach-render-observe probe session" )] fn run_headless_probe(socket: &Path, report: &Path) -> i32 { use std::fmt::Write as _; use std::sync::mpsc; let Some(mut state) = State::new_headless(900, 600, "(connecting...)") else { eprintln!("pmacs-gpu probe: no wgpu adapter available"); return 3; }; let (tx, rx) = mpsc::channel::(); let client = match attach::connect_with_sink(socket, move |event| tx.send(event).is_ok()) { Ok(client) => client, Err(error) => { eprintln!("pmacs-gpu probe: attach failed: {error}"); return 4; } }; state.set_frontend_id(client.frontend_id()); let mut facts = ProbeFacts { server_protocol_version: client.server_protocol_version(), ..ProbeFacts::default() }; // Ask the daemon to open the acceptance terminal in THIS frontend's // window. Going through a real key press is the point: the daemon's // `terminal.open` targets the invoking frontend, so this is what // puts the attached window on a terminal buffer. if let Some(chord) = std::env::var_os("PMACS_GPU_PROBE_OPEN_KEY") .and_then(|value| value.into_string().ok()) .and_then(|value| value.chars().next()) { let _ = client.send_key(ProtocolKey::Char(chord), Modifiers::CTRL | Modifiers::ALT); } let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); let mut sent_input = false; let mut sent_resize = false; while std::time::Instant::now() < deadline { let Ok(event) = rx.recv_timeout(std::time::Duration::from_millis(200)) else { continue; }; match event { AttachEvent::Disconnected(reason) => { facts.disconnect = Some(reason); break; } AttachEvent::Message(msg) => { let is_snapshot = matches!(*msg, InstanceMessage::BufferSnapshot { .. }); if let InstanceMessage::TerminalFrame(frame) = msg.as_ref() { facts.frames += 1; facts.last_frame_cols = frame.size.cols; facts.last_frame_rows = frame.size.rows; facts.last_frame_text = frame_probe_text(frame); facts.last_title.clone_from(&frame.title); } state.apply_attach_message(*msg); if is_snapshot { // The dual declaration: a byte viewport for a // document, a cell size for a terminal. The daemon // keeps whichever matches. if let Some(buffer_id) = state.current_buffer_id { let (start, end) = state.view_range; let _ = client.send_viewport( buffer_id, pmacs_protocol::ByteRange { start, end }, 0, ); } if let Some((buffer_id, size)) = state.terminal_declaration_if_changed() && client.send_terminal_resize(buffer_id, size).is_ok() { facts.declarations += 1; state.note_terminal_declaration_sent(buffer_id, size); } } if state.terminal.is_some() { facts.entered_terminal_mode = true; // Render a REAL frame through the real composition // path, then record that it composited. let pixels = state.render_offscreen(); let first = pixels.first().copied().unwrap_or_default(); if pixels.iter().any(|&b| b != first) { facts.rendered_nonuniform_frames += 1; } if !sent_input && facts.frames >= 1 { sent_input = true; // Real child input over the real wire. let _ = client.send_key(ProtocolKey::Char('x'), Modifiers::NONE); let _ = client.send_key(ProtocolKey::Enter, Modifiers::NONE); } if !sent_resize && facts.frames >= 2 { sent_resize = true; state.resize(700, 500); if let Some((buffer_id, size)) = state.terminal_declaration_if_changed() && client.send_terminal_resize(buffer_id, size).is_ok() { facts.declarations += 1; facts.resized_cols = size.cols; facts.resized_rows = size.rows; state.note_terminal_declaration_sent(buffer_id, size); } } if facts.resized_cols > 0 && facts.last_frame_cols == facts.resized_cols { facts.observed_resized_frame = true; } } if facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 { break; } } } } let mut out = String::new(); let _ = writeln!( out, "server_protocol_version={}", facts.server_protocol_version ); let _ = writeln!(out, "declarations={}", facts.declarations); let _ = writeln!(out, "frames={}", facts.frames); let _ = writeln!( out, "rendered_nonuniform_frames={}", facts.rendered_nonuniform_frames ); let _ = writeln!(out, "entered_terminal_mode={}", facts.entered_terminal_mode); let _ = writeln!( out, "observed_resized_frame={}", facts.observed_resized_frame ); let _ = writeln!(out, "last_frame_rows={}", facts.last_frame_rows); let _ = writeln!(out, "last_frame_cols={}", facts.last_frame_cols); let _ = writeln!(out, "resized_rows={}", facts.resized_rows); let _ = writeln!(out, "resized_cols={}", facts.resized_cols); let _ = writeln!(out, "last_title={}", facts.last_title.unwrap_or_default()); let _ = writeln!(out, "last_frame_text={}", facts.last_frame_text); let _ = writeln!(out, "disconnect={}", facts.disconnect.unwrap_or_default()); if let Err(error) = std::fs::write(report, out) { eprintln!( "pmacs-gpu probe: writing {} failed: {error}", report.display() ); return 5; } 0 } /// Named observations the headless probe reports back to the acceptance. #[derive(Default)] struct ProbeFacts { server_protocol_version: u32, declarations: u32, frames: u32, rendered_nonuniform_frames: u32, entered_terminal_mode: bool, observed_resized_frame: bool, last_frame_rows: u32, last_frame_cols: u32, resized_rows: u32, resized_cols: u32, last_title: Option, last_frame_text: String, disconnect: Option, } /// One-line printable text of a terminal frame, for probe reporting. fn frame_probe_text(frame: &TerminalFrame) -> String { let mut text = String::new(); for cell in &frame.cells { match &cell.glyph { pmacs_protocol::Glyph::Char(ch) => text.push(*ch), pmacs_protocol::Glyph::Cluster(bytes) => { text.push_str(&String::from_utf8_lossy(bytes)); } pmacs_protocol::Glyph::Continuation => {} } } text.retain(|ch| !ch.is_control()); text } /// 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), } } "--headless-probe" => { let socket = iter.next().unwrap_or_else(|| { eprintln!("pmacs-gpu: --headless-probe requires a socket path"); std::process::exit(2); }); let report = iter.next().unwrap_or_else(|| { eprintln!("pmacs-gpu: --headless-probe requires a report path"); std::process::exit(2); }); Mode::HeadlessProbe { socket: PathBuf::from(socket), report: PathBuf::from(report), } } "--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 { // `None` in the headless render-test path (F-014): a windowless State // that renders to an offscreen texture instead of a surface. window: Option>, device: wgpu::Device, queue: wgpu::Queue, surface: Option>, config: wgpu::SurfaceConfiguration, font_system: FontSystem, /// What sanitized assembly retained (Q#F6): the default family /// and the bundled face ID — the total-fallback anchors for /// every font resolution. font_defaults: FontDefaults, /// Derived metrics for the current preference (Q#F6); default /// reproduces the BASE_* constants exactly. fm: FontMetrics, /// The family every shaped attrs run selects (Q#F6): the /// sanitized default at assembly; replaced only by a /// four-style-monospace-validated resolution in /// `apply_font_facts` — rejected requests fall back HERE, so /// the accessor is total. resolved_family: String, /// The resolved family's measured normal-face advance at the /// current code metrics (the [`ADVANCE_PROBE`] result, Q#F6). /// Authoritative for gutter geometry once a `FontFacts` has been /// applied; `None` until then, falling back to today's /// first-shaped-glyph sampling. measured_mono_advance: Option, /// The retained normalized code-buffer scroll (framing Q#F6): /// slice-local `line == 0` always holds, this is the `vertical` /// pixel residual within the top source line's visual runs, and /// `horizontal` stays 0 (glyphon 0.11 never applies it). Nonzero /// only after caret-following crossed into a wrapped run; /// explicit wheel/minimap jumps clear it, `BufferSnapshot` /// resets it (buffer-scoped view state), and every full reshape /// reapplies it instead of installing `Scroll::default`. code_scroll_residual: f32, 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. Summary replacement rebuilds the table; accepted text /// edits update the affected line immediately (or rebuild after /// structural/batched edits). 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/Q#SL10 — the status band's shaped right rich text. status_buffer: Buffer, /// Rich runs currently installed in the right status buffer. /// `None` is the invalidation sentinel; an empty vector is valid. status_runs: Option>, /// The independently left-aligned status buffer. status_left_buffer: Buffer, /// Rich runs currently installed in the left status buffer. status_left_runs: Option>, /// Latest atomically validated custom statusline replacement. statusline_segments: Option, /// 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, /// Arc 1a Q#C5 — the live in-buffer completion popup (protocol /// v15), or `None` when closed. completion: Option, /// Shaped row text for the completion dropdown, one line per /// candidate ("glyph label detail"). completion_buffer: Buffer, /// Dedicated text renderer for the completion dropdown (its own /// layer over the buffer, like the menu's / minibuffer's). completion_text_renderer: TextRenderer, /// Completion dropdown background + selection quads. completion_bg_vertex_buffer: ReusableVertexBuffer, /// Minimap vertex bytes cached by [`MinimapCacheKey`] — /// rebuilding rescanned every line shape per frame. minimap_cache: Option<(MinimapCacheKey, Vec)>, /// Line-number gutter mode (UX gutter arc, GPU side). Shipped by the /// daemon over `InstanceMessage::LineNumbers` (protocol v14). `Off` ⇒ /// zero coordinate change: `gutter_width_px()` is 0 and every shift site /// is a no-op. Relative/Hybrid are rendered locally against the GPU's /// own cursor line. line_numbers: LineNumberMode, /// Shaped right-aligned line numbers, one per visible code line — its /// own text layer over the code, aligned row-for-row (same line height). gutter_buffer: Buffer, /// Dedicated renderer for the gutter number layer (like the menu / mb). gutter_text_renderer: TextRenderer, /// The daemon-resolved UI face table (themes arc Q#TH7, protocol /// v16). Exact-name lookup only — inheritance is resolved /// daemon-side, so the frontend never walks. Complete replacement /// per `ThemeFacts`; a face absent from the map means "use the /// site's hardcoded default". Applied per draw through the /// `face_fg_or` / `face_wash_or` / `modeline_face_colors` / /// `diag_face_rgba` resolvers (Q#TH5 mask + `Default` mapping). faces: HashMap, /// Vterm Stage 3 — the installed terminal frame and its derived /// paint plan, or `None` in document mode. The two-state machine is /// explicit: `BufferSnapshot` always leaves terminal mode, a valid /// matching `TerminalFrame` always enters it. terminal: Option, /// The terminal geometry last declared to the daemon, with the /// buffer it described. Suppresses an unchanged re-declaration and /// forces a fresh one after a buffer switch. last_terminal_size_sent: Option<(BufferId, CellSize)>, /// Whether an invalid terminal frame has already been reported. /// Bounds the log while a bad producer keeps sending. terminal_frame_error_latched: bool, /// Last terminal cell a motion or drag was reported at. Pixel-rate /// motion inside ONE cell is not new information for the daemon — /// the document drag path dedupes by hit byte for the same reason. /// Cleared on press, release, and every exit from terminal mode, so /// a gesture that returns to the same cell still reports. last_terminal_pointer_cell: Option, /// One shaped buffer per planned text run. Rebuilt only when the /// plan changes, never per frame. terminal_text_buffers: Vec, /// Dedicated renderer for terminal glyphs, so they draw in their own /// layer with terminal clipping rather than through the document /// text pass. terminal_text_renderer: TextRenderer, } /// Vterm Stage 3 — the GPU's terminal mode. struct TerminalLocal { /// The terminal identity buffer this frame describes. buffer_id: BufferId, /// The last valid frame. Retained verbatim so an identical /// re-send can be recognized and skipped without a rebuild. frame: TerminalFrame, /// Cell-space paint data derived from `frame`. plan: TerminalPaintPlan, } /// Kind-glyph column for a completion row: the LSP /// `CompletionItemKind` numeric code → the single-char glyph the TUI /// popup uses (`crate::completion::CompletionItemKind::glyph`'s /// mapping, replicated — the GPU crate doesn't depend on `pmacs`). /// Unknown codes fall back to the plain-text dot, per the LSP /// "accept extended kinds gracefully" contract. fn completion_kind_glyph(kind: u8) -> char { match kind { 2..=4 => 'f', // method / function / constructor 5 | 10 => 'p', // field / property 6 | 21 => 'v', // variable / constant 7 | 22 => 'C', // class / struct 8 => 'I', // interface 9 => 'M', // module 13 | 20 => 'E', // enum / enum member 14 => 'k', // keyword 15 => 's', // snippet 25 => 't', // type parameter 17 | 19 => '/', // file / folder _ => '.', } } /// Latest validated custom statusline replacement (Q#SL7/Q#SL10). #[derive(Clone, Debug, PartialEq, Eq)] struct StatuslineSegmentsLocal { buffer_id: BufferId, left: Vec, right: Vec, } /// Validate the complete untrusted statusline payload before any state /// changes. Numeric and namespace policy lives only in pmacs-protocol. fn validate_statusline_segments( left: &[StatuslineSegment], right: &[StatuslineSegment], ) -> Result<(), &'static str> { let count = left .len() .checked_add(right.len()) .ok_or("segment count overflow")?; if count > MAX_STATUSLINE_PROVIDERS { return Err("too many segments"); } let mut total_text_bytes = 0usize; for segment in left.iter().chain(right) { if segment.text.is_empty() { return Err("empty segment text"); } if segment.text.len() > MAX_STATUSLINE_SEGMENT_BYTES { return Err("segment text too long"); } if segment.text.chars().any(char::is_control) { return Err("segment text contains a control character"); } total_text_bytes = total_text_bytes .checked_add(segment.text.len()) .ok_or("total text length overflow")?; if total_text_bytes > MAX_STATUSLINE_TOTAL_TEXT_BYTES { return Err("total segment text too long"); } if segment.face.len() > MAX_STATUSLINE_FACE_BYTES { return Err("segment face too long"); } if segment.face.chars().any(char::is_control) { return Err("segment face contains a control character"); } if !is_modeline_face_name(&segment.face) { return Err("segment face is outside ui.modeline"); } } Ok(()) } /// The wire-authoritative status facts (Q#S1, protocol v8; `message` /// since v15), 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 core's transient status message (`pmacs.editor.set_status` /// — "12 references", LSP errors, ...), or `None` when clear. message: Option, } /// 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 in-buffer completion popup (Arc 1a Q#C5, protocol v15), /// mirrored from a `CompletionPopup` whose `anchor` was `Some`. The /// dropdown anchors at the glyph rect of `anchor` (a byte offset — /// the caret mapping reused), one row per candidate; navigation and /// accept round-trip into the daemon's completion shadow. #[derive(Clone, Debug, PartialEq, Eq)] struct CompletionLocal { /// Buffer the popup targets. Rendering and the key gates check /// this against `current_buffer_id` so a popup can never act /// against a buffer it wasn't opened in (buffer switches also /// clear the whole mirror at the `BufferSnapshot` arm; this is /// the belt to that suspender). buffer_id: BufferId, /// Byte offset of the prefix start. anchor: u64, /// Bytes of typed prefix at `anchor` (reserved for a bolded- /// prefix refinement; unused by the first render). #[allow( dead_code, reason = "shipped on the wire for the bolded-prefix refinement" )] prefix_len: u32, /// Windowed candidate rows (label / kind / detail), best-first. rows: Vec, /// Highlighted row within `rows`. selected: Option, /// Total candidate count (reserved for an "i/total" hint). #[allow( dead_code, reason = "shipped on the wire for the i/total hint refinement" )] 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 terminal-cell gesture if the daemon speaks v19+. /// /// The terminal twin of [`Self::send_pointer`]: same frontend-side /// version gate, cells instead of source bytes. fn send_terminal_pointer( &self, buffer_id: BufferId, coord: CellCoord, kind: ProtocolMouseKind, mods: Modifiers, ) { let Some(client) = self.attach_client.as_ref() else { return; }; if client.server_protocol_version() < 19 { return; } if let Err(e) = client.send_terminal_pointer(buffer_id, coord, kind, mods) { eprintln!("pmacs-gpu: send_terminal_pointer failed: {e}"); } } /// Declare the current terminal cell geometry when it has changed. /// /// Called after every applied message and after any real geometry /// change. An unchanged size sends nothing, so a redraw storm /// produces no wire traffic; a changed one sends exactly once. fn flush_terminal_declaration(&mut self) { let Some(client) = self.attach_client.as_ref() else { return; }; if client.server_protocol_version() < 19 { return; } let Some(state) = self.state.as_mut() else { return; }; let Some((buffer_id, size)) = state.terminal_declaration_if_changed() else { return; }; match client.send_terminal_resize(buffer_id, size) { Ok(()) => state.note_terminal_declaration_sent(buffer_id, size), Err(e) => eprintln!("pmacs-gpu: send_terminal_resize failed: {e}"), } } /// Resolve a pixel to a terminal cell, or `None` when this window is /// not in terminal mode or the pixel is outside the grid. /// /// The status band and the padding past the last whole column are /// deliberately not terminal hits: a gesture there belongs to the /// chrome, not the child. fn terminal_pointer_hit(&self, x: f64, y: f64) -> Option<(BufferId, CellCoord)> { let state = self.state.as_ref()?; let terminal = state.terminal.as_ref()?; let coord = crate::terminal::hit_test_cell( x as f32, y as f32, (TEXT_LEFT, TEXT_TOP), state.mono_advance(), state.fm.code_line_height(), terminal.plan.size, )?; Some((terminal.buffer_id, coord)) } /// 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 { .. } | Mode::HeadlessProbe { .. } => "(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() { // Render a concise, actionable line in the window // itself (F-003) — e.g. a non-CRDT daemon — rather // than a generic "see stderr" the user won't read. state.set_text(&e.window_status()); } } } } } #[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); // Arc 1a Q#C6 — the completion popup is NON-modal, so it // never flips the intercept gate (typing stays // optimistic; the daemon's after-edit refresh re-ships // the popup). Only the keys whose *default GPU handling // is wrong under a popup* need this flag: Esc (below, // else it's the local quit) and RET/TAB (the optimistic // gate further down, else they'd insert instead of // accept). C-n/C-p/C-g already round-trip as command // chords, Up/Down as forwarded motion keys — the daemon's // completion shadow handles all of them. let completion_open = self .state .as_ref() .is_some_and(State::completion_open_for_current_buffer); // Escape cancels an active intercept (e.g. a running // search) or dismisses the completion popup; otherwise it // stays the local quit. if matches!(key.logical_key, Key::Named(NamedKey::Escape)) { if intercept || completion_open { 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 both Ctrl and Alt* are held — the // AltGr signature on Windows (LCtrl+RAlt) — it's text // input, not a command chord. Strip those modifiers (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. Alt alone is left intact so // macOS Option-as-Meta still reaches the keymap; on layouts // where AltGr isn't Ctrl+Alt this is a no-op. 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; } // Arc 1a Q#C6 — with the popup open, RET and TAB mean // "accept", not "insert \n / \t": skip the optimistic // path so they round-trip into the daemon's // dispatch_completion_key. Everything else stays // optimistic. let completion_takes_key = completion_open && matches!(pkey, ProtocolKey::Enter | ProtocolKey::Tab); if !completion_takes_key && 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 edit near the viewport edge can // scroll (a wrap-inducing insert, a Backspace // above the top); 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}"); } // Vterm Stage 3 — a resize is a real geometry change, so // the cell grid is re-derived and declared here. The // daemon resizes the shared PTY only if this frontend is // the durable controller. self.flush_terminal_declaration(); } // 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; } // Vterm Stage 3 — inside the terminal clip, motion is a // terminal gesture. Consumed before minimap scrubbing // and document hit testing: terminal mode paints no // minimap and has no source bytes to resolve. if state.terminal.is_some() { let dragging = state.pointer_drag_active; if let Some((buffer_id, coord)) = self.terminal_pointer_hit(position.x, position.y) { // Sub-cell motion resolves to the same cell and // carries nothing new. Report only on a cell // change, matching the document drag path's // hit-byte dedupe — otherwise pixel-rate motion // becomes pixel-rate wire traffic, and every one // of those is a daemon-side gesture. let state = self.state.as_mut().expect("checked above"); if state.terminal_motion_is_new(coord) { let mods = translate_mods(self.modifiers); let kind = if dragging { ProtocolMouseKind::Drag(ProtocolMouseButton::Left) } else { ProtocolMouseKind::Move }; self.send_terminal_pointer(buffer_id, coord, kind, mods); } } 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, state.fm); // 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); if state.terminal.is_some() { let hit = self.terminal_pointer_hit(x, y); let state = self.state.as_mut().expect("checked above"); let kind = match button_state { ElementState::Pressed => { // A press that MISSES the grid (the status // band, the trailing padding) starts no // drag: arming the flag there would make a // later in-grid motion send a `Drag` with no // preceding `Down`. state.pointer_drag_active = hit.is_some(); ProtocolMouseKind::Down(ProtocolMouseButton::Left) } ElementState::Released => { // A release always ends the drag, including // one that wandered outside the grid. state.pointer_drag_active = false; ProtocolMouseKind::Up(ProtocolMouseButton::Left) } }; // A press or release always reports, and it re-arms // the motion dedupe: the first drag after a press // must reach the daemon even at the cell the press // landed on. state.last_terminal_pointer_cell = None; if let Some((buffer_id, coord)) = hit { self.send_terminal_pointer(buffer_id, coord, kind, mods); } return; } 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; } // Vterm Stage 3 — a right-click in the terminal clip is // a terminal gesture; the daemon decides between child // reporting and the editor context menu, so the anchor // is remembered here exactly as for a document click. if state.terminal.is_some() { state.menu_anchor_px = (x, y); if let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, y) { let mods = translate_mods(self.modifiers); self.send_terminal_pointer( buffer_id, coord, ProtocolMouseKind::Down(ProtocolMouseButton::Right), mods, ); } 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) / state.fm.code_line_height()).round() as i64 } }; if lines == 0 { return; } // Vterm Stage 3 — the terminal's scrollback belongs to // the daemon-side view, not to this frontend's local // scroll, so a wheel tick crosses the wire as a // terminal gesture instead of moving `scroll_top`. if state.terminal.is_some() { if let Some((x, y)) = state.pointer_pos && let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, y) { let mods = translate_mods(self.modifiers); let kind = if lines < 0 { ProtocolMouseKind::ScrollUp } else { ProtocolMouseKind::ScrollDown }; self.send_terminal_pointer(buffer_id, coord, kind, mods); } 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.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}"); } // Vterm Stage 3 — the dual declaration. After every // snapshot the frontend re-declares BOTH its byte // viewport (above) and its terminal cell size, because // an empty terminal identity snapshot does not announce // itself as a terminal. The daemon keeps whichever one // matches the buffer's kind, which is what breaks the // otherwise circular "need a frame to know to ask for // one" dependency. self.flush_terminal_declaration(); let Some(state) = self.state.as_mut() else { return; }; 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.on_daemon_disconnected("(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. /// /// `Tab` qualifies alongside printable chars because its default /// binding (`buffer.tab`) reduces to a plain `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. `Enter` /// does NOT: since Q#AI1 (docs/auto-indent-framing.md) RET binds /// `edit.newline-and-indent`, whose inserted text depends on the /// current line's indentation — and round-tripping is also what makes /// RET rebindings (e.g. the buffer list's visit binding) reachable /// from this frontend at all. 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 (`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 { // Auto-pairing Q#AP1: the built-in pair charset always // round-trips so the typed opener and the pairing hook's // closer land as adjacent daemon-peer undo units, and // dispatch-path CUA type-over / skip-over-close apply. An // optimistic pair char would put the opener on this // frontend's peer with the closer on the daemon's — uncleanly // undoable from either side. ProtocolKey::Char(ch) if !ch.is_control() && !is_builtin_pair_char(ch) => { Some(ch.encode_utf8(chbuf)) } 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); Self::assemble( Some(window), Some(surface), device, queue, config, initial_text, &[], ) } /// Build a windowless `State` that renders to an offscreen texture, for /// the headless render tests (F-014) and the Vterm Stage 3 headless /// attach probe. Returns `None` when no GPU adapter is available (a /// dev box with no working Vulkan, or CI without lavapipe), so the /// caller skips rather than fails. fn new_headless(width: u32, height: u32, initial_text: &str) -> Option { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::LowPower, compatible_surface: None, force_fallback_adapter: false, })) .ok()?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("pmacs-gpu headless device"), required_features: wgpu::Features::empty(), required_limits: wgpu::Limits::default(), ..wgpu::DeviceDescriptor::default() })) .ok()?; let format = wgpu::TextureFormat::Rgba8UnormSrgb; let config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format, width: width.max(1), height: height.max(1), present_mode: wgpu::PresentMode::Fifo, desired_maximum_frame_latency: 2, alpha_mode: wgpu::CompositeAlphaMode::Auto, view_formats: vec![], }; Some(Self::assemble( None, None, device, queue, config, initial_text, // Font fixtures exist only in test builds; the release-mode // attach probe runs on the bundled face alone. headless_extra_font_sources(), )) } /// Build the window-agnostic half of a `State` — font system, glyph /// atlas, the three text renderers, quad/squiggle pipelines, and every /// render-input field — given an already-created device/queue and the /// target `format`. Shared by the windowed `new` and headless /// `new_headless` (F-014). #[allow(clippy::too_many_lines)] // one large struct literal. fn assemble( window: Option>, surface: Option>, device: wgpu::Device, queue: wgpu::Queue, config: wgpu::SurfaceConfiguration, initial_text: &str, extra_font_sources: &[&'static [u8]], ) -> Self { // Pipelines, atlas, and any offscreen texture must all share the // render-target format; `config.format` is the single source. let format = config.format; // Q#F6: construction always starts at the default metrics; // a preference arriving later re-metrics via apply_font_facts. let fm = FontMetrics::default(); // Q#F6: sanitized current-order assembly — system fonts, the // bundled face (ID retained), the same-family collision // filter, generic defaults, THEN FontSystem construction so // its monospace-ID set sees the final database. let (mut font_system, font_defaults) = build_font_system(extra_font_sources); 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, 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); // Arc 1a Q#C5 — a renderer for the completion dropdown layer. let completion_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); // UX gutter — a renderer for the line-number layer. let gutter_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); // Vterm Stage 3 — terminal glyphs draw in their own layer with // their own clip; interleaving them with document text would // subject them to the document's gutter offset and wrapping. let terminal_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); let quad_renderer = QuadRenderer::new(&device, format); let squiggle_renderer = SquiggleRenderer::new(&device, 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(fm.code_font_size(), fm.code_line_height()), ); 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(fm.status_font_size(), fm.status_line_height()), ); status_buffer.set_size( &mut font_system, Some(config.width as f32), Some(fm.status_band_height()), ); status_buffer.set_wrap(&mut font_system, Wrap::None); let mut status_left_buffer = Buffer::new( &mut font_system, Metrics::new(fm.status_font_size(), fm.status_line_height()), ); status_left_buffer.set_size( &mut font_system, Some(config.width as f32), Some(fm.status_band_height()), ); status_left_buffer.set_wrap(&mut font_system, Wrap::None); let mut menu_buffer = Buffer::new( &mut font_system, Metrics::new(fm.menu_font_size(), fm.menu_line_height()), ); menu_buffer.set_size( &mut font_system, Some(MENU_MAX_WIDTH), Some(config.height as f32), ); // Rows stay rows (framing Q#F6): the three row-oriented popup // buffers never wrap — their protocols, row windows, selection // quads, and hit tests all assign exactly one row height per // source line, and a label wrapping after a font change would // paint on a second visual row that hit-tests as the following // item. The pixel bounds keep owning horizontal clipping. menu_buffer.set_wrap(&mut font_system, Wrap::None); let mut mb_buffer = Buffer::new( &mut font_system, Metrics::new(fm.mb_drop_font_size(), fm.mb_drop_line_height()), ); mb_buffer.set_size( &mut font_system, Some(MB_DROP_MAX_WIDTH), Some(config.height as f32), ); mb_buffer.set_wrap(&mut font_system, Wrap::None); // Completion dropdown buffer (Arc 1a): the minibuffer // dropdown's metrics, its own layer. let mut completion_buffer = Buffer::new( &mut font_system, Metrics::new(fm.mb_drop_font_size(), fm.mb_drop_line_height()), ); completion_buffer.set_size( &mut font_system, Some(MB_DROP_MAX_WIDTH), Some(config.height as f32), ); completion_buffer.set_wrap(&mut font_system, Wrap::None); // Line-number gutter buffer (UX gutter arc): same font size + line // height as the code buffer so its rows align one-for-one. let mut gutter_buffer = Buffer::new( &mut font_system, Metrics::new(fm.code_font_size(), fm.code_line_height()), ); gutter_buffer.set_size( &mut font_system, Some(config.width as f32), Some(config.height as f32), ); let (current_line_starts, current_line_char_starts) = line_offset_tables(initial_text); let mut state = Self { window, device, queue, surface, config, font_system, font_defaults, fm: FontMetrics::default(), resolved_family: DEFAULT_FONT_FAMILY.to_owned(), measured_mono_advance: None, code_scroll_residual: 0.0, 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_runs: None, status_left_buffer, status_left_runs: None, statusline_segments: None, 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(), completion: None, completion_buffer, completion_text_renderer, completion_bg_vertex_buffer: ReusableVertexBuffer::new(), minimap_cache: None, line_numbers: LineNumberMode::Off, faces: HashMap::new(), gutter_buffer, gutter_text_renderer, terminal: None, last_terminal_size_sent: None, terminal_frame_error_latched: false, last_terminal_pointer_cell: None, terminal_text_buffers: Vec::new(), terminal_text_renderer, }; // Real drawable dimensions from construction (framing Q#F6): // wrapping and `shape_until_cursor` must use the same clip the // painter uses, and a v16 daemon never sends the FontFacts // that would sync them later. state.sync_buffer_dimensions(); // Shape the initial text through the shared chunk path so the // `buffer.lines` ↔ `line_chunk_cache` invariant holds from // construction — the caret/anchor projection (framing Q#F6) // inverts the per-line chunk cache, and a directly-set buffer // would leave it empty. state.reshape(); state } 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 edit on the bottom // visible line that wraps (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. Visual-run aware // (framing Q#F6): the confirming identical `CursorByte` has // `moved == false`, so deferring wrapped-run repair here // would leave a newly wrapped caret off-screen indefinitely. self.ensure_caret_painted(); let viewport = self.viewport_send_if_changed(predicted.buffer_id); 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 caret_was_painted = self.caret_painted_in_code_clip(); 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.refresh_minimap_shapes_after_edits(&edits, line_count_before); self.translate_cached_anchors(&edits); // A newline edit can cross a gutter digit boundary (9 -> 10, // 99 -> 100). Synchronize the painter-derived code width // before any reshape so cosmic-text wraps at the final clip. let geometry_changed = self.sync_buffer_dimensions(); // 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 geometry_changed || !(single_line_edit && self.try_reshape_line(edits[0])) { self.reshape(); } if geometry_changed && caret_was_painted { self.ensure_caret_painted(); } 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); } } /// Keep minimap horizontal geometry in lock-step with accepted text /// edits instead of waiting for the next debounced style summary. /// The common one-line edit updates one cached shape; line-structure /// or batched edits rebuild the table because their intermediate /// coordinates need not describe the final line partition. fn refresh_minimap_shapes_after_edits( &mut self, edits: &[TextProjectionEdit], line_count_before: usize, ) { if edits.len() == 1 && self.current_line_starts.len() == line_count_before && self.current_line_shapes.len() == self.current_line_starts.len() { let line = self .current_line_starts .partition_point(|&start| start <= edits[0].start) .saturating_sub(1); let start = self.current_line_starts[line] as usize; let end = self .current_line_starts .get(line + 1) .map_or(self.current_text.len(), |next| *next as usize - 1); self.current_line_shapes[line] = minimap_line_shape(&self.current_text[start..end]); } else { self.current_line_shapes = minimap_line_shapes(&self.current_text); } self.minimap_cache = None; } /// 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; } let caret_was_painted = self.caret_painted_in_code_clip(); 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.current_line_shapes = minimap_line_shapes(text); self.minimap_cache = None; let geometry_changed = self.sync_buffer_dimensions(); self.reshape(); if geometry_changed && caret_was_painted { self.ensure_caret_painted(); } 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; // The completion popup too (Arc 1a): its anchor is a // byte in the prior buffer, and the producer never // ships a close for a viewport that no longer exists // (first-sight of the new buffer stays silent) — a // retained popup would render against the new rope AND // keep hijacking Esc/RET/TAB. The daemon-side session // was already invalidated by the switch. self.completion = None; // PR #120 round 3 finding 1 — the remaining // buffer-scoped facts, same reasoning: search and menu // popups anchor in the prior buffer AND gate key and // pointer interception (`daemon_intercepts_keys`, the // pointer arms), and the new buffer's first CLOSED // state is suppressed daemon-side, so no close message // ever comes — a retained popup would hijack input // forever. The status band's name/counts describe the // buffer we just left; the producer's reset contract // re-ships the new buffer's facts on its first frame. // The minibuffer deliberately survives: it is one // global core instance, matching the producer's // surviving `last_minibuffer` baseline. self.search_prompt = None; self.menu = None; self.status_facts = None; self.statusline_segments = None; self.status_runs = None; self.status_left_runs = 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. // The caret-follow residual is buffer-scoped view // state and resets with it; the global font // preference/metrics survive (framing Q#F6). self.scroll_top = 0; self.code_scroll_residual = 0.0; self.last_viewport_sent = None; // Vterm Stage 3 — a snapshot ALWAYS leaves terminal // mode, including a terminal→terminal switch. The prior // frame describes another session's screen, and the // daemon has already dropped its own baseline, so the // next valid frame is authoritative for whatever this // buffer turns out to be. self.exit_terminal_mode(); if !self.set_text(&text) { // Even byte-identical A -> B snapshots can clear a // prior minimap, so the new buffer's shaping clip // must still be synchronized before rebuilding. self.sync_buffer_dimensions(); 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.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), // Q#S1 (protocol v8; `message` since v15) — the // wire-authoritative half of the status band: name, // modified, whole-file diag counts, and the transient // status message (LSP command summaries). InstanceMessage::StatusFacts { buffer_id, name, modified, diag_errors, diag_warnings, message, } => { self.status_facts = Some(StatusFactsLocal { buffer_id, name, modified, diag_errors, diag_warnings, message, }); self.request_redraw(); None } // UX gutter (protocol v14): the daemon owns the per-window // line-number mode; apply it to our local gutter state and // repaint on change. InstanceMessage::LineNumbers { mode, .. } => { if self.line_numbers != mode { let caret_was_painted = self.caret_painted_in_code_clip(); self.line_numbers = mode; return self.reflow_dynamic_code_geometry(caret_was_painted); } None } // Themes Q#TH7 (protocol v16): the daemon-resolved UI face // table — complete replacement each send. The status-band // shaping cache MUST be invalidated here (Q#TH8): the // E:/W: counter colors are baked into glyphon rich-text // attributes at compose time and `refresh_status_line` // skips re-shaping while the composed strings are // unchanged, so a diag-face change with constant counts // would keep stale colors indefinitely without this. InstanceMessage::ThemeFacts { faces } => { self.faces = faces.into_iter().map(|f| (f.name, f.style)).collect(); self.status_runs = None; self.status_left_runs = None; self.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.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.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. // // The follow is visual-run aware (framing Q#F6): the // shared helper closes the old source-line-only hole // where a caret on a wrapped continuation run below // the band never scrolled into view. if moved { self.ensure_caret_painted(); if let Some(vp) = self.viewport_send_if_changed(buffer_id) { return Some(vp); } } self.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.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.request_redraw(); None } // Arc 1a Q#C5/Q#C6 — the in-buffer completion dropdown. // A close (`anchor: None`) always applies — the daemon may // ship it carrying a buffer this window just switched away // from, and dropping it would wedge a stale popup. An OPEN // for a buffer this window isn't showing is dropped (the // CrdtOp rule). InstanceMessage::CompletionPopup { buffer_id, anchor, prefix_len, rows, selected, total, } => { let Some(anchor) = anchor else { self.completion = None; self.request_redraw(); return None; }; if self.current_buffer_id != Some(buffer_id) { return None; } self.completion = Some(CompletionLocal { buffer_id, anchor, prefix_len, rows, selected, total, }); self.request_redraw(); None } // Arc 4 stage 3 (Q#SL7/Q#SL10) — validate the entire // untrusted replacement before changing either side. InstanceMessage::StatuslineSegments { buffer_id, left, right, } => { if let Err(reason) = validate_statusline_segments(&left, &right) { eprintln!("pmacs-gpu: ignoring invalid StatuslineSegments: {reason}"); return None; } let next = StatuslineSegmentsLocal { buffer_id, left, right, }; if self.statusline_segments.as_ref() != Some(&next) { self.statusline_segments = Some(next); self.status_runs = None; self.status_left_runs = None; self.request_redraw(); } None } // Arc 4 stage 2 (framing Q#F6/Q#F7) — the global font // preference. Authoritative per attachment: `(None, None)` // is a real reset to the sanitized defaults, never // inferred from silence. The rebuild may change the // visible slice (new wrapping/metrics), so re-declare the // viewport from the final normalized origin. InstanceMessage::FontFacts { family, size_centi_px, } => { self.apply_font_facts(family.as_deref(), size_centi_px); // Q#F6 + Vterm Stage 3: new metrics mean a new cell // grid. The terminal shape/geometry caches are dropped // here and stay dropped until an authoritative frame at // the matching size arrives, so nothing paints at the // old advance under the new font. self.invalidate_terminal_shaping(); self.current_buffer_id .and_then(|bid| self.viewport_send_if_changed(bid)) } InstanceMessage::TerminalFrame(frame) => { self.apply_terminal_frame(frame); None } _ => None, } } /// Install a decoded terminal frame, or reject it whole. /// /// Rejection is total by design: a partially applied frame would mix /// cells from two screens. An invalid frame therefore keeps the /// previous valid one, requests no redraw, and reports one latched /// diagnostic instead of painting something the daemon never /// authorized. fn apply_terminal_frame(&mut self, frame: TerminalFrame) { if self.current_buffer_id != Some(frame.buffer_id) { // A frame for a buffer this window is no longer showing. // The daemon clears its baseline on every snapshot, so the // authoritative frame for the buffer we DO show is already // on its way. return; } if let Err(error) = frame.validate() { if !self.terminal_frame_error_latched { self.terminal_frame_error_latched = true; eprintln!("pmacs-gpu: rejecting invalid TerminalFrame: {error}"); } return; } self.terminal_frame_error_latched = false; if self .terminal .as_ref() .is_some_and(|terminal| terminal.frame == frame) { // A duplicate valid frame does no work at all: no plan // rebuild, no reshape, no redraw. return; } let plan = TerminalPaintPlan::build(&frame, Self::terminal_palette()); let buffer_id = frame.buffer_id; self.terminal = Some(TerminalLocal { buffer_id, frame, plan, }); self.rebuild_terminal_text_buffers(); self.request_redraw(); } /// The frontend defaults `Color::Default` resolves against. fn terminal_palette() -> TerminalPalette { let fg = plain_text_color(); TerminalPalette { default_fg: [fg.r(), fg.g(), fg.b()], default_bg: [ (WINDOW_BG_RGBA[0] * 255.0) as u8, (WINDOW_BG_RGBA[1] * 255.0) as u8, (WINDOW_BG_RGBA[2] * 255.0) as u8, ], } } /// Show a disconnect notice, leaving terminal mode first. /// /// Terminal mode prepares NO document code layer, and the terminal /// glyph layer keeps painting the last frame it was given. Setting /// the text without leaving terminal mode therefore writes into a /// layer nothing draws, and the user is left looking at a frozen, /// live-looking terminal that silently ignores input — with GPU /// auto-reconnect a named deferral, until relaunch. The F-008 /// "make the teardown visible" contract applies to terminal mode /// too. fn on_daemon_disconnected(&mut self, notice: &str) { self.exit_terminal_mode(); if !self.set_text(notice) { // Byte-identical text still needs a repaint: the frame that // is on screen is the terminal's, not this notice. self.reshape(); } self.request_redraw(); } /// Leave terminal mode and drop every terminal-only cache. fn exit_terminal_mode(&mut self) { self.terminal = None; self.terminal_text_buffers.clear(); self.terminal_frame_error_latched = false; self.last_terminal_size_sent = None; self.last_terminal_pointer_cell = None; } /// Drop shaping and geometry caches without leaving terminal mode. /// /// Used when the font changes: the installed frame is still the /// child's authoritative screen, but every cached shape was measured /// at the old advance. fn invalidate_terminal_shaping(&mut self) { if self.terminal.is_some() { self.rebuild_terminal_text_buffers(); } self.last_terminal_size_sent = None; } /// Reshape one cosmic-text buffer per planned run. /// /// One buffer per RUN, not per row: a row-wide buffer would let a /// wide or cluster glyph's shaped advance decide where the following /// column starts, and terminal columns belong to the child. fn rebuild_terminal_text_buffers(&mut self) { let Some(terminal) = self.terminal.as_ref() else { self.terminal_text_buffers.clear(); return; }; let metrics = Metrics::new(self.fm.code_font_size(), self.fm.code_line_height()); let advance = self.mono_advance(); let family = self.resolved_family.clone(); let runs: Vec<_> = terminal .plan .runs .iter() .map(|run| { ( run.text.clone(), run.cells as f32 * advance, run.bold, run.italic, ) }) .collect(); let mut buffers = Vec::with_capacity(runs.len()); for (text, width, bold, italic) in runs { let mut buffer = Buffer::new(&mut self.font_system, metrics); // No wrapping: a run occupies exactly the cells the child // gave it, and overflow is a clip, never a second row. buffer.set_wrap(&mut self.font_system, Wrap::None); buffer.set_size( &mut self.font_system, Some(width.max(1.0)), Some(metrics.line_height), ); let attrs = Attrs::new() .family(Family::Name(&family)) .weight(if bold { glyphon::cosmic_text::Weight::BOLD } else { glyphon::cosmic_text::Weight::NORMAL }) .style(if italic { glyphon::cosmic_text::Style::Italic } else { glyphon::cosmic_text::Style::Normal }); buffer.set_text( &mut self.font_system, &text, &attrs, Shaping::Advanced, None, ); buffer.shape_until_scroll(&mut self.font_system, false); buffers.push(buffer); } self.terminal_text_buffers = buffers; } /// The terminal content rectangle's pixel origin. /// /// Deliberately not `text_left()`: terminal mode draws no document /// gutter, so the grid starts at the plain text inset. fn terminal_origin() -> (f32, f32) { (TEXT_LEFT, TEXT_TOP) } /// The cell grid this window's drawable rectangle admits, or `None` /// when it cannot fit one whole cell. fn terminal_cell_viewport(&self) -> Option { let (origin_x, origin_y) = Self::terminal_origin(); let width = self.config.width as f32 - origin_x; let height = text_area_bottom(self.config.height, self.fm) - origin_y; crate::terminal::cell_viewport( width, height, self.mono_advance(), self.fm.code_line_height(), ) } /// Pixel rectangle of a cell run in the terminal grid. fn terminal_run_rect(&self, run: crate::terminal::CellRun) -> (f32, f32, f32, f32) { let (ox, oy) = Self::terminal_origin(); let advance = self.mono_advance(); let line = self.fm.code_line_height(); ( ox + run.start_col as f32 * advance, oy + run.row as f32 * line, (run.end_col - run.start_col) as f32 * advance, line, ) } /// Backgrounds, straight underlines, the selection wash, and the /// terminal clip, as one quad batch drawn under the glyphs. /// /// Runs whose resolved background equals the window clear color are /// dropped: the clear already painted them, and emitting a /// full-screen quad per frame for the common case is pure waste. fn terminal_quad_vertex_bytes(&self) -> Vec { let Some(terminal) = self.terminal.as_ref() else { return Vec::new(); }; let window_bg = Self::terminal_palette().default_bg; let mut rects = Vec::new(); for bg in &terminal.plan.backgrounds { if bg.color == window_bg { continue; } let (x, y, w, h) = self.terminal_run_rect(bg.run); rects.push(MinimapRect { x, y, w, h, color: rgb_to_quad(bg.color, 1.0), }); } // Straight underline forms as fixed-cell quads; curly rides the // squiggle pipeline, which owns the sine wave. for underline in &terminal.plan.underlines { if underline.style == UnderlineStyle::Curly { continue; } let (x, y, w, h) = self.terminal_run_rect(underline.run); let color = rgb_to_quad(underline.color, 1.0); let thickness = TERMINAL_UNDERLINE_PX; let baseline = y + h - thickness * 2.0; match underline.style { UnderlineStyle::Double => { rects.push(MinimapRect { x, y: baseline, w, h: thickness, color, }); rects.push(MinimapRect { x, y: baseline + thickness * 2.0, w, h: thickness, color, }); } UnderlineStyle::Dotted | UnderlineStyle::Dashed => { // Dotted and dashed differ only in duty cycle; both // are stepped along the run so a one-cell run still // shows at least one mark. let period = if underline.style == UnderlineStyle::Dotted { TERMINAL_UNDERLINE_PX * 3.0 } else { TERMINAL_UNDERLINE_PX * 8.0 }; let duty = if underline.style == UnderlineStyle::Dotted { 0.5 } else { 0.625 }; let mut at = x; while at < x + w { let seg = (period * duty).min(x + w - at); rects.push(MinimapRect { x: at, y: baseline, w: seg, h: thickness, color, }); at += period; } } _ => rects.push(MinimapRect { x, y: baseline, w, h: thickness, color, }), } } // Terminal selection is the editor's, not the child's: it draws // as a separate wash through the existing `ui.selection` site // and never rewrites a cell's own style. let selection_color = self.face_wash_or("ui.selection", TERMINAL_SELECTION_RGBA); for run in &terminal.plan.selection { let (x, y, w, h) = self.terminal_run_rect(*run); rects.push(MinimapRect { x, y, w, h, color: selection_color, }); } rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } /// Curly terminal underlines through the existing squiggle pipeline. fn terminal_squiggle_vertex_bytes(&self) -> Vec { let Some(terminal) = self.terminal.as_ref() else { return Vec::new(); }; let rects: Vec = terminal .plan .underlines .iter() .filter(|underline| underline.style == UnderlineStyle::Curly) .map(|underline| { let (x, y, w, h) = self.terminal_run_rect(underline.run); MinimapRect { x, y: y + h - DIAG_SQUIGGLE_PX, w, h: DIAG_SQUIGGLE_PX, color: rgb_to_quad(underline.color, 1.0), } }) .collect(); squiggles_to_vertex_bytes(&rects, self.config.width, self.config.height) } /// The child cursor's quad, painted through the caret primitive so /// it lands over the glyph it sits on. fn terminal_cursor_vertex_bytes(&self) -> Vec { let Some(terminal) = self.terminal.as_ref() else { return Vec::new(); }; let Some(cursor) = terminal.plan.cursor else { return Vec::new(); }; let (x, y, w, h) = self.terminal_run_rect(cursor); rects_to_vertex_bytes( &[MinimapRect { x, y, w, h, color: TERMINAL_CURSOR_RGBA, }], self.config.width, self.config.height, ) } /// A geometry declaration for the current buffer if it /// changed, else `None`. /// /// Called after a snapshot and after any real geometry change /// (window resize, scale, font). An equal size is silent, so a /// redraw storm produces no wire traffic. fn terminal_declaration_if_changed(&mut self) -> Option<(BufferId, CellSize)> { let buffer_id = self.current_buffer_id?; let size = self.terminal_cell_viewport()?; if self.last_terminal_size_sent == Some((buffer_id, size)) { return None; } Some((buffer_id, size)) } /// Whether terminal motion at `coord` is new information. /// /// Records the cell as reported either way, so a caller that skips /// the send still advances the memo. Press and release reset it /// (`last_terminal_pointer_cell = None`), which is what lets the /// first drag after a press reach the daemon even at the cell the /// press landed on. fn terminal_motion_is_new(&mut self, coord: CellCoord) -> bool { let changed = self.last_terminal_pointer_cell != Some(coord); self.last_terminal_pointer_cell = Some(coord); changed } /// Record a declaration the caller actually put on the wire. /// /// Separate from [`Self::terminal_declaration_if_changed`] so a /// FAILED send does not leave a size believed-declared: the daemon /// would still hold the old geometry while this frontend suppressed /// every retry as unchanged. fn note_terminal_declaration_sent(&mut self, buffer_id: BufferId, size: CellSize) { self.last_terminal_size_sent = Some((buffer_id, size)); } /// True while the completion popup is open **for the buffer this /// window currently shows** — the predicate the key gates (Esc, /// RET/TAB) and the render path share, so a stale mirror can /// never act against a foreign buffer. fn completion_open_for_current_buffer(&self) -> bool { self.completion .as_ref() .is_some_and(|c| Some(c.buffer_id) == self.current_buffer_id) } /// 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, self.fm).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 } /// Bring the own caret's VISUAL run into the drawable code window /// (framing Q#F6) — the shared follow helper for the `CursorByte` /// arm, the optimistic edit completion, and the font/resize /// transactions. The coarse source-line [`Self::scroll_to_cursor`] /// runs first (the byte may be outside the shaped slice), then /// cosmic-text's `Buffer::shape_until_cursor` follows wrapped /// layout runs vertically. Its `Scroll.horizontal` result is /// discarded — glyphon 0.11 never applies horizontal scroll when /// placing glyphs, so retaining it would make state claim a /// scroll the painter never displays. Ends by re-normalizing the /// scroll; callers declare the viewport only after that final /// source origin is stable. fn ensure_caret_painted(&mut self) { let Some(own) = self.own_cursor else { return; }; if self.current_buffer_id != Some(own.buffer_id) { return; } if self.scroll_to_cursor() { // Pure scroll: retained lines keep their shape caches; // only newly exposed lines shape. self.rebuild_lines_reusing_scroll(); } let byte = own.byte.min(self.current_text.len() as u64); if let Some(cursor) = self.code_byte_to_layout_cursor(byte) { self.buffer .shape_until_cursor(&mut self.font_system, cursor, false); } self.normalize_code_scroll(); self.request_redraw(); } /// Monospace glyph advance in px, used to size the line-number /// gutter (UX gutter arc). Once a `FontFacts` has been applied the /// measured NORMAL-face probe advance is authoritative (framing /// Q#F6) — different monospaced faces need not share an advance, /// so sampling an arbitrary (possibly bold/italic) code glyph /// could skew the gutter. Before any probe: today's behavior — /// the first shaped glyph, else the ratio-scaled constant. fn mono_advance(&self) -> f32 { if let Some(advance) = self.measured_mono_advance { return advance; } self.buffer .layout_runs() .flat_map(|run| run.glyphs.iter()) .next() .map_or(self.fm.gutter_advance_fallback(), |g| g.w) } /// Width in px the line-number gutter reserves on the left, or 0 when /// disabled (UX gutter arc, Q#UX3): `digits * advance + gap`. Mirrors /// the TUI's `Window::gutter_width`; the unit here is pixels. fn gutter_width_px(&self) -> f32 { if !self.line_numbers.is_on() { return 0.0; } let lines = self.current_line_starts.len().max(1); let want = decimal_digits(lines) as f32 * self.mono_advance() + GUTTER_GAP_PX; // Fit guard (mirrors the TUI's too-narrow disable): never reserve so // much gutter that the text area collapses. `text_bounds_right` is // the text clip edge (minimap-aware); if the gutter would leave less // than `MIN_TEXT_WIDTH_PX` past `TEXT_LEFT`, drop it this frame // rather than shift `text_left` to or past the clip and render into // a degenerate `left >= right` rectangle. let avail = self.text_bounds_right() as f32 - TEXT_LEFT; if want + MIN_TEXT_WIDTH_PX > avail { 0.0 } else { want } } /// The code's left origin in px: `TEXT_LEFT` plus the gutter. Every /// byte→pixel x site adds this instead of the bare `TEXT_LEFT` (Q#UX2), /// and the pixel→byte hit-test subtracts it. fn text_left(&self) -> f32 { TEXT_LEFT + self.gutter_width_px() } /// The GPU's own cursor's 0-based buffer line, or `0` when there's no /// own cursor in the displayed buffer (relative/hybrid then count from /// the top — a rare transient). Derived from the whole-buffer line /// table, so it's independent of the shaped slice. fn cursor_line(&self) -> usize { let byte = match self.own_cursor.as_ref() { Some(c) if Some(c.buffer_id) == self.current_buffer_id => c.byte, _ => 0, }; self.current_line_starts .partition_point(|&start| start <= byte) .saturating_sub(1) } /// Reshape the gutter buffer to the right-aligned line numbers for the /// currently-shaped code lines (UX gutter arc). The projection mirrors /// the code layout's VISUAL runs (framing Q#F6): each source line's /// number rides its first run and wrapped continuation runs get blank /// gutter rows, then the code buffer's normalized vertical scroll is /// applied verbatim — both buffers share the line height, so rows stay /// aligned when wrapping or a caret-follow residual is active. No-op /// when the gutter is off. fn refresh_gutter_buffer(&mut self) { use std::fmt::Write as _; if !self.line_numbers.is_on() { return; } let digits = decimal_digits(self.current_line_starts.len().max(1)) as usize; let first = self.shaped_top; // Relative/Hybrid measure distance from the cursor's buffer line; // Absolute ignores it. Rebuilt every render, so it tracks the cursor. let cursor_line = self.cursor_line(); let mode = self.line_numbers; let n = self.buffer.lines.len(); let mut text = String::new(); for i in 0..n { if i > 0 { text.push('\n'); } let num = mode.number_for(first + i, cursor_line).unwrap_or(0); let _ = write!(text, "{num:>digits$}"); // Continuation blanks: one empty row per wrapped run past // the first. Unlaid lines (below the drawable window) count // as one row; their gutter rows are equally invisible. let runs = self .buffer .line_layout(&mut self.font_system, i) .map_or(1, |layout| layout.len().max(1)); for _ in 1..runs { text.push('\n'); } } let family = self.resolved_family.clone(); self.gutter_buffer.set_text( &mut self.font_system, &text, &Attrs::new().family(Family::Name(&family)), Shaping::Advanced, None, ); self.gutter_buffer .set_scroll(Scroll::new(0, self.code_scroll_residual, 0.0)); self.gutter_buffer .shape_until_scroll(&mut self.font_system, false); } /// 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. /// Text-relative x for hit testing, classifying the gutter band first /// (UX gutter, Q#UX6). A click left of the text origin (`raw_x < 0`, /// i.e. inside the gutter) is not a text hit — it clamps to `0.0`, the /// line start, rather than feeding glyphon a negative x (undefined). /// Mirrors the TUI's saturate-to-column-0 affordance and is the stable /// seam a future gutter marker would branch on instead of relying on /// glyphon's negative-x edge behavior. fn gutter_aware_rel_x(&self, x: f64) -> f32 { let raw_x = x as f32 - self.text_left(); if self.line_numbers.is_on() && raw_x < 0.0 { 0.0 } else { raw_x } } 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 = self.gutter_aware_rel_x(x); 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); // An explicit jump owns the viewport wholesale: it also clears // the caret-follow residual (framing Q#F6), so a retained // sub-line offset can't pin the top row. A wheel-up at the top // clamp edge still scrolls when its only remaining motion IS // the residual; a wheel-down pinned at the bottom clamp keeps // the residual (nothing below to reveal). let residual_scrolls_up = delta < 0 && self.code_scroll_residual != 0.0; if new_top == self.scroll_top && !residual_scrolls_up { return None; } self.code_scroll_residual = 0.0; 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, self.fm, ) } /// 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, fm: FontMetrics) -> f32 { let max_chars = menu .rows .iter() .map(|r| r.label.chars().count()) .max() .unwrap_or(0); (max_chars as f32 * fm.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, self.fm)); let h = menu.rows.len() as f64 * f64::from(self.fm.menu_row_height()); if x < ax || x >= ax + w || y < ay || y >= ay + h { return None; } let row = (((y - ay) / f64::from(self.fm.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(), self.fm, )?; let centered = target.saturating_sub(estimated_visible_lines(self.config.height, self.fm) / 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.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.resolved_family); self.line_chunk_cache[shaped_idx] = chunks; self.view_range = (vstart, vend); self.buffer.shape_until_scroll(&mut self.font_system, false); // The edited line's wrap count can shrink under a retained // residual, advancing the slice-local scroll (framing Q#F6); // its rebuilds re-derive `view_range` themselves. self.normalize_code_scroll(); self.hit_map_dirty = true; self.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, &self.resolved_family)); cache.push(chunks); } } self.buffer.lines = lines; self.line_chunk_cache = cache; self.shaped_top = new_top; self.buffer .set_scroll(Scroll::new(0, self.code_scroll_residual, 0.0)); self.buffer.shape_until_scroll(&mut self.font_system, false); self.normalize_code_scroll(); self.hit_map_dirty = true; if any_reused || self.line_chunk_cache.is_empty() { self.styled_redraw_deadline = None; self.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.resolved_family); self.line_chunk_cache[i] = chunks; any = true; } } if any { self.buffer.shape_until_scroll(&mut self.font_system, false); // Adornment chunks can change a line's wrap count under a // retained residual (framing Q#F6). self.normalize_code_scroll(); 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.request_redraw(); } // ----------------------------------------------------------------- // Themes (Q#TH5/Q#TH7/Q#TH8): UI-face resolution. Faces arrive // daemon-resolved over `ThemeFacts`; lookups are exact-name. A set // face owns its surface within its stage-1 mask, and a `Default` // component inside the mask maps to the frontend's PLAIN rendering // — the buffer-text default fg / the window-background bg — never // the old chrome constant. An UNSET face keeps the site constant. // ----------------------------------------------------------------- /// fg resolution for an {fg}-mask site: face set → its fg /// (`Default` ↦ the plain text color); unset → `fallback` /// (today's site constant). fn face_fg_or(&self, name: &str, fallback: Color) -> Color { match self.faces.get(name) { Some(f) => cell_color_to_glyphon(f.fg).unwrap_or_else(plain_text_color), None => fallback, } } /// Wash resolution for a {bg}-mask face: face set → its bg RGB /// carrying the site's current alpha (`Default` bg ↦ no wash — a /// fully transparent quad); unset → `fallback` (today's wash /// constant, alpha included). fn face_wash_or(&self, name: &str, fallback: [f32; 4]) -> [f32; 4] { match self.faces.get(name) { Some(f) => match cell_color_to_glyphon(f.bg) { Some(c) => glyphon_to_rgba(c, fallback[3]), None => [0.0, 0.0, 0.0, 0.0], }, None => fallback, } } /// `Some((band quad rgba, band text color))` when `ui.modeline` /// is set — mask {fg, bg, reverse}: `Default` bg ↦ the window /// background (an untinted band), `Default` fg ↦ the plain text /// color, `reverse` swaps the two after mapping. `None` when /// unset: each band site keeps its own constant. fn modeline_face_colors(&self) -> Option<([f32; 4], Color)> { let f = self.faces.get("ui.modeline")?; let text = cell_color_to_glyphon(f.fg).unwrap_or_else(plain_text_color); let quad = match cell_color_to_glyphon(f.bg) { Some(c) => glyphon_to_rgba(c, 1.0), None => WINDOW_BG_RGBA, }; Some(if f.reverse { (glyphon_to_rgba(text, 1.0), rgba_to_glyphon(quad)) } else { (quad, text) }) } /// Diag-family TEXT color (Q#TH5 policy): the `ui.diag.*` face's /// fg when set with a concrete color, else `fallback` (the /// built-in severity constant). Unlike [`Self::face_fg_or`], a /// set face's `Default` fg maps to the BUILT-IN color, never /// plain — the severity color doubles as the minimap presence /// encoding, so a plain severity is unrepresentable. fn diag_face_fg_or(&self, name: &str, fallback: Color) -> Color { self.faces .get(name) .and_then(|f| cell_color_to_glyphon(f.fg)) .unwrap_or(fallback) } /// Diag-family quad color — [`Self::diag_face_fg_or`]'s rgba /// twin, keyed by decoration kind. fn diag_face_rgba(&self, kind: DecorationKind, fallback: [f32; 4]) -> [f32; 4] { let name = match kind { DecorationKind::DiagnosticError => "ui.diag.error", DecorationKind::DiagnosticWarning => "ui.diag.warning", DecorationKind::DiagnosticInfo => "ui.diag.info", DecorationKind::DiagnosticHint => "ui.diag.hint", _ => return fallback, }; match self .faces .get(name) .and_then(|f| cell_color_to_glyphon(f.fg)) { Some(c) => glyphon_to_rgba(c, 1.0), None => fallback, } } /// The OWN-window wash color for a background decoration kind: /// the local selection and search washes resolve their faces; /// peer rects (`collect_peer_rects`) deliberately keep the /// constants — peer theming rides the deferred peer-cursor /// palette arc (Q#TH5, round 2 finding 9). fn own_wash_color(&self, kind: DecorationKind) -> Option<[f32; 4]> { let fallback = decoration_kind_to_bg_color(kind)?; let name = match kind { DecorationKind::Selection => "ui.selection", DecorationKind::SearchMatch => "ui.search.match", DecorationKind::SearchMatchActive => "ui.search.match.active", _ => return Some(fallback), }; Some(self.face_wash_or(name, fallback)) } /// The band's left-segment text color, mirroring the content /// precedence in [`Self::compose_status_left_runs`]. fn status_left_color(&self) -> Color { let fallback = Color::rgb(200, 200, 210); if self.minibuffer.is_some() || self .search_prompt .as_ref() .is_some_and(|s| Some(s.buffer_id) == self.current_buffer_id) { return self.face_fg_or("ui.minibuffer", fallback); } let has_message = self .status_facts .as_ref() .filter(|f| Some(f.buffer_id) == self.current_buffer_id) .is_some_and(|f| f.message.is_some()); if has_message { return self.face_fg_or("ui.statusline", fallback); } self.modeline_face_colors() .map_or(fallback, |(_, text)| text) } fn status_right_base_color(&self) -> Color { self.modeline_face_colors() .map_or(Color::rgb(168, 168, 180), |(_, text)| text) } /// Resolve an exact custom face against `ThemeFacts`. The producer /// already normalizes custom entries to an {fg}-only style; absent /// entries, `ui.modeline`, and defensive `Default` all select the /// effective base modeline color. fn status_segment_color(&self, face: &str, base: Color) -> Color { if face == "ui.modeline" { return base; } self.faces .get(face) .and_then(|style| cell_color_to_glyphon(style.fg)) .unwrap_or(base) } fn current_statusline_segments(&self) -> Option<&StatuslineSegmentsLocal> { self.statusline_segments .as_ref() .filter(|segments| Some(segments.buffer_id) == self.current_buffer_id) } /// Compose the protected right group. Custom providers precede the /// legacy diagnostic/cursor/scroll suffix. Custom boundaries are one /// base-colored space; the built-in suffix retains its exact two-space /// separators. fn compose_status_runs(&self) -> Vec<(String, Color)> { use std::fmt::Write as _; let base = self.status_right_base_color(); let mut runs = Vec::new(); if let Some(custom) = self.current_statusline_segments() { for segment in &custom.right { if !runs.is_empty() { runs.push((" ".to_owned(), base)); } runs.push(( segment.text.clone(), self.status_segment_color(&segment.face, base), )); } } let mut builtins = Vec::new(); if let Some(facts) = self .status_facts .as_ref() .filter(|facts| Some(facts.buffer_id) == self.current_buffer_id) { if facts.diag_errors > 0 { builtins.push(( format!("E:{}", facts.diag_errors), self.diag_face_fg_or("ui.diag.error", Color::rgb(241, 76, 76)), )); } if facts.diag_warnings > 0 { builtins.push(( format!("W:{}", facts.diag_warnings), self.diag_face_fg_or("ui.diag.warning", 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(|&start| start as usize <= byte) .saturating_sub(1); cursor_row = line; let line_start = self.current_line_starts.get(line).copied().unwrap_or(0) as usize; let col = self .current_text .get(line_start..byte) .map_or(0, |text| text.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.fm), self.current_line_starts.len(), cursor_row, )); builtins.push((readout, base)); if !runs.is_empty() { runs.push((" ".to_owned(), base)); } for (index, builtin) in builtins.into_iter().enumerate() { if index > 0 { runs.push((" ".to_owned(), base)); } runs.push(builtin); } runs } /// Compose the left group. Minibuffer, isearch, and transient /// messages suppress custom left segments; ordinary buffer identity /// starts at the leading edge but may be fully clipped by the right group. fn compose_status_left_runs(&self) -> Vec<(String, Color)> { if let Some(minibuffer) = self.minibuffer.as_ref() { return vec![( format!("{}{}", minibuffer.prompt, minibuffer.input), self.status_left_color(), )]; } if let Some(search) = self .search_prompt .as_ref() .filter(|search| Some(search.buffer_id) == self.current_buffer_id) { let label = if search.regex { "Regex I-search: " } else { "I-search: " }; let count = if search.query.is_empty() { String::new() } else if search.invalid { " [invalid]".to_owned() } else if search.total == 0 { " [no match]".to_owned() } else { format!( " ({}/{})", search.active.map_or(0, |active| active + 1), search.total ) }; return vec![( format!("{label}{}{count}", search.query), self.status_left_color(), )]; } if let Some(message) = self .status_facts .as_ref() .filter(|facts| Some(facts.buffer_id) == self.current_buffer_id) .and_then(|facts| facts.message.as_deref()) { return vec![(message.to_owned(), self.status_left_color())]; } let base = self.status_left_color(); let identity = match self .status_facts .as_ref() .filter(|facts| Some(facts.buffer_id) == self.current_buffer_id) { Some(facts) if facts.modified => format!("{} ●", facts.name), Some(facts) => facts.name.clone(), None => String::new(), }; let mut runs = Vec::new(); if !identity.is_empty() { runs.push((identity, base)); } if let Some(custom) = self.current_statusline_segments() { for segment in &custom.left { if !runs.is_empty() { runs.push((" ".to_owned(), base)); } runs.push(( segment.text.clone(), self.status_segment_color(&segment.face, base), )); } } runs } /// Re-shape only when the complete ordered rich-run key changes. /// Cache advancement follows successful installation and shaping. fn refresh_status_line(&mut self) { let right = self.compose_status_runs(); let left = self.compose_status_left_runs(); let family = self.resolved_family.clone(); let default_attrs = Attrs::new().family(Family::Name(&family)); if self.status_runs.as_ref() != Some(&right) { let rich = right .iter() .map(|(text, color)| (text.as_str(), default_attrs.clone().color(*color))); 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_runs = Some(right); } if self.status_left_runs.as_ref() != Some(&left) { let rich = left .iter() .map(|(text, color)| (text.as_str(), default_attrs.clone().color(*color))); self.status_left_buffer.set_rich_text( &mut self.font_system, rich, &default_attrs, Shaping::Advanced, None, ); self.status_left_buffer .shape_until_scroll(&mut self.font_system, false); self.status_left_runs = Some(left); } } /// The status band's background quad (Q#S2): a full-width strip /// under the band text. fn status_band_vertex_bytes(&self) -> Vec { // Themes Q#TH5: a set ui.modeline face owns the band surface. let color = self .modeline_face_colors() .map_or(STATUS_BAND_BG, |(quad, _)| quad); let rect = MinimapRect { x: 0.0, y: text_area_bottom(self.config.height, self.fm), w: self.config.width as f32, h: self.fm.status_band_height(), color, }; 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") }); let family = self.resolved_family.clone(); self.menu_buffer.set_text( &mut self.font_system, &text, &Attrs::new().family(Family::Name(&family)), 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, self.fm); let mut rects = vec![MinimapRect { x: ax, y: ay, w, h: menu.rows.len() as f32 * self.fm.menu_row_height(), color: MENU_BG, }]; for (i, row) in menu.rows.iter().enumerate() { let ry = ay + i as f32 * self.fm.menu_row_height(); if row.separator { rects.push(MinimapRect { x: ax + MENU_PAD_X, y: ry + self.fm.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: self.fm.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")); let family = self.resolved_family.clone(); self.mb_buffer.set_text( &mut self.font_system, &text, &Attrs::new().family(Family::Name(&family)), Shaping::Advanced, None, ); self.mb_buffer .shape_until_scroll(&mut self.font_system, false); } /// The visible slice `(first, count)` of the dropdown candidates /// (audit F-007), clamped to the rows that fit above the band and /// scrolled to keep the selection on screen. `None` when closed, /// candidate-free, or too short for a row. See [`mb_dropdown_window`]. fn mb_visible_window(&self) -> Option<(usize, usize)> { let mb = self.minibuffer.as_ref()?; let band_top = text_area_bottom(self.config.height, self.fm); mb_dropdown_window( mb.candidates.len(), mb.selected.map_or(0, |s| s as usize), band_top, self.fm, ) } /// 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. The height is the *visible* row count /// (F-007), so `top_y` never goes above the window top. /// `refresh_mb_buffer` must have run so the width measurement is /// current. fn mb_dropdown_rect(&self) -> Option<(f32, f32, f32)> { let (_first, count) = self.mb_visible_window()?; 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, self.fm); let top_y = band_top - count as f32 * self.fm.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((first, count)) = self.mb_visible_window() else { return Vec::new(); }; let Some((x, top_y, width)) = self.mb_dropdown_rect() else { return Vec::new(); }; let mut rects = vec![MinimapRect { x, y: top_y, w: width, h: count as f32 * self.fm.mb_drop_row_height(), color: MENU_BG, }]; // Highlight the selection at its row *within the visible window*; // by construction it always falls inside [first, first + count). if let Some(sel) = mb.selected.map(|s| s as usize) && sel >= first && sel < first + count { rects.push(MinimapRect { x, y: top_y + (sel - first) as f32 * self.fm.mb_drop_row_height(), w: width, h: self.fm.mb_drop_row_height(), color: MENU_SELECTED_BG, }); } rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } /// Re-shape the completion dropdown rows (Arc 1a Q#C5), one line /// per candidate: kind glyph, label, then the dimmable detail. /// Empty when the popup is closed. fn refresh_completion_buffer(&mut self) { let text = self.completion.as_ref().map_or_else(String::new, |comp| { comp.rows .iter() .map(|row| { let glyph = completion_kind_glyph(row.kind); match row.detail.as_deref() { Some(detail) => format!("{glyph} {} {detail}", row.label), None => format!("{glyph} {}", row.label), } }) .collect::>() .join("\n") }); let family = self.resolved_family.clone(); self.completion_buffer.set_text( &mut self.font_system, &text, &Attrs::new().family(Family::Name(&family)), Shaping::Advanced, None, ); self.completion_buffer .shape_until_scroll(&mut self.font_system, false); } /// The pixel position of the completion popup's byte anchor: /// `(x, line_top_y, line_height)` of the glyph the anchor sits /// before — the caret mapping (`caret_rect`) reused for a second /// byte. `None` when the popup is closed or the anchor is /// scrolled out of the visible slice (the popup then simply /// doesn't draw this frame; scrolling back restores it). fn completion_anchor_px(&mut self) -> Option<(f32, f32, f32)> { if !self.completion_open_for_current_buffer() { return None; // never paint against a foreign buffer's rope } let anchor = self.completion.as_ref()?.anchor; let (vstart, vend) = self.view_range; if vend <= vstart || anchor < vstart || anchor > vend { return None; } // The caret mapping, visual-run aware (framing Q#F6): the // anchor's run, not its source line's first run. Off the // drawable window (a wrapped run below the band, or above a // caret-follow residual) counts as scrolled out. let (x, top, line_height) = self.code_byte_px(anchor)?; let y = TEXT_TOP + top; let bottom = text_area_bottom(self.config.height, self.fm); if y >= bottom || y + line_height <= TEXT_TOP { return None; } Some((self.text_left() + x, y, line_height)) } /// Layout of the completion dropdown: `(first_row, row_count, /// left_x, top_y)`. Anchored on the row *below* the anchor's line /// (growing downward toward the status band); flips above when /// nothing fits below — the TUI overlay's placement rule. The /// visible slice windows around the selection so it stays on /// screen when fewer rows fit than the wire shipped (the F-007 /// discipline). fn completion_dropdown_layout(&mut self) -> Option<(usize, usize, f32, f32)> { let comp = self.completion.as_ref()?; let n = comp.rows.len(); if n == 0 { return None; } let sel = comp.selected.map_or(0, |s| s as usize); let (ax, line_top, line_h) = self.completion_anchor_px()?; let band_top = text_area_bottom(self.config.height, self.fm); let below_px = band_top - (line_top + line_h); let above_px = line_top - TEXT_TOP; let max_below = (below_px / self.fm.mb_drop_row_height()).floor() as usize; let max_above = (above_px / self.fm.mb_drop_row_height()).floor() as usize; let (avail, below) = if max_below >= 1 { (max_below, true) } else { (max_above, false) }; if avail == 0 { return None; } let count = n.min(avail); let first = if n <= count { 0 } else { sel.saturating_sub(count / 2).min(n - count) }; let top_y = if below { line_top + line_h } else { line_top - count as f32 * self.fm.mb_drop_row_height() }; Some((first, count, ax, top_y)) } /// Dropdown geometry `(left, top_y, width)`: as wide as the widest /// row (clamped, the minibuffer bounds), left edge at the anchor /// column shifted back from the window's right margin. /// `refresh_completion_buffer` must have run so the width /// measurement is current. fn completion_dropdown_rect(&mut self) -> Option<(f32, f32, f32)> { let (_first, _count, ax, top_y) = self.completion_dropdown_layout()?; let widest = self .completion_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 left = ax.min((self.config.width as f32 - width).max(0.0)); Some((left, top_y, width)) } /// Completion dropdown background + selection-highlight quads. /// Empty when closed or the anchor is off-screen. fn completion_dropdown_vertex_bytes(&mut self) -> Vec { let Some(selected) = self.completion.as_ref().map(|c| c.selected) else { return Vec::new(); }; let Some((first, count, _ax, _ty)) = self.completion_dropdown_layout() else { return Vec::new(); }; let Some((x, top_y, width)) = self.completion_dropdown_rect() else { return Vec::new(); }; let mut rects = vec![MinimapRect { x, y: top_y, w: width, h: count as f32 * self.fm.mb_drop_row_height(), color: MENU_BG, }]; if let Some(sel) = selected.map(|s| s as usize) && sel >= first && sel < first + count { rects.push(MinimapRect { x, y: top_y + (sel - first) as f32 * self.fm.mb_drop_row_height(), w: width, h: self.fm.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, ) -> Option { if self.current_buffer_id != Some(buffer_id) { return None; } if self .current_summary .as_ref() .is_some_and(|summary| generation < summary.generation) { return None; } let caret_was_painted = self.caret_painted_in_code_clip(); self.current_line_shapes = minimap_line_shapes(&self.current_text); self.current_summary = Some(FileStyleSummaryState { generation, lines }); // PR #120 round 1 finding 1: a newly accepted summary can // arrive at an UNCHANGED generation (theme recolor, // diagnostic republish) and the minimap cache keys only on // (generation, dims, scroll) — without an explicit drop the // stale vertices survive until an edit, resize, or scroll. // The daemon payload-suppresses identical summaries, so every // summary accepted here is genuinely new and the invalidation // is precise. self.minimap_cache = None; self.reflow_dynamic_code_geometry(caret_was_painted) } /// `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 = spans_from_segments(segments); } /// `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, self.fm).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) } /// Rebuild the shaped slice from `scroll_top` and reapply the /// retained normalized scroll (framing Q#F6) — the raw builder /// shared by [`Self::reshape`] and the [`Self::normalize_code_scroll`] /// fold loop. Callers that end a "final code shape" must run the /// normalizer after so the slice-local `line == 0` invariant is /// re-established. fn rebuild_code_slice(&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, &self.resolved_family)); cache.push(chunks); } self.buffer.lines = lines; self.line_chunk_cache = cache; self.shaped_top = top; self.buffer .set_scroll(Scroll::new(0, self.code_scroll_residual, 0.0)); 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; } /// Re-establish the normalized code-scroll invariant after a /// final code shape (framing Q#F6): cosmic-text advances the /// slice-local `scroll.line` when new wrapping/metrics push the /// retained vertical residual across source lines. Fold that /// delta into the whole-file `scroll_top`, retain the residual, /// rebuild from the new source origin, and repeat until /// `line == 0`. Every iteration strictly advances the clamped /// source origin; a non-advancing or past-EOF fold clamps to the /// last source line with a default scroll instead of looping. /// `scroll.horizontal` is discarded throughout — glyphon 0.11 /// never applies it when placing glyphs, so retaining it would /// claim a scroll the painter never displays. fn normalize_code_scroll(&mut self) { loop { let scroll = self.buffer.scroll(); if scroll.horizontal != 0.0 { self.buffer .set_scroll(Scroll::new(scroll.line, scroll.vertical, 0.0)); } if scroll.line == 0 { self.code_scroll_residual = scroll.vertical.max(0.0); return; } let last_line = self.current_line_starts.len().saturating_sub(1); let new_top = self.shaped_top + scroll.line; if new_top > self.shaped_top && new_top <= last_line { self.scroll_top = new_top; self.code_scroll_residual = scroll.vertical; self.rebuild_code_slice(); } else { self.scroll_top = last_line; self.code_scroll_residual = 0.0; self.rebuild_code_slice(); return; } } } fn reshape(&mut self) { self.rebuild_code_slice(); self.normalize_code_scroll(); // Full restyle: release any held post-jump frame (Q#M6). self.styled_redraw_deadline = None; self.request_redraw(); } /// Ask the window to repaint. A no-op headless (no window), where the /// render tests drive `render_offscreen` directly (F-014). fn request_redraw(&self) { if let Some(window) = &self.window { window.request_redraw(); } } /// One dimension helper for ALL seven buffers (framing Q#F6): /// metrics and the current REAL drawable dimensions change /// atomically via `set_metrics_and_size` — `set_metrics` alone /// deliberately preserves old dimensions, and the old `resize()` /// only touched four of the seven buffers (the "numbers stop at /// 10" class of skew). Code and gutter get the drawable code /// clip's height (and code its clip width, so wrapping and /// `shape_until_cursor` use the same clip the painter uses); the /// status pair gets the derived band; the row popups get the /// surface height — their protocols window rows themselves. /// `set_metrics_and_size` no-ops when nothing changed, so calling /// this eagerly is cheap. Returns whether the code buffer's /// metrics or drawable dimensions changed and therefore require /// reshaping before its next frame. fn sync_buffer_dimensions(&mut self) -> bool { let fm = self.fm; let width = self.config.width as f32; let height = self.config.height as f32; let code_metrics = Metrics::new(fm.code_font_size(), fm.code_line_height()); let code_width = (self.text_bounds_right() as f32 - self.text_left()).max(0.0); let code_height = (text_area_bottom(self.config.height, fm) - TEXT_TOP).max(0.0); let code_layout_changed = self.buffer.metrics() != code_metrics || self.buffer.size() != (Some(code_width), Some(code_height)); self.buffer.set_metrics_and_size( &mut self.font_system, code_metrics, Some(code_width), Some(code_height), ); self.gutter_buffer.set_metrics_and_size( &mut self.font_system, code_metrics, Some(width), Some(code_height), ); let status_metrics = Metrics::new(fm.status_font_size(), fm.status_line_height()); self.status_buffer.set_metrics_and_size( &mut self.font_system, status_metrics, Some(width), Some(fm.status_band_height()), ); self.status_left_buffer.set_metrics_and_size( &mut self.font_system, status_metrics, Some(width), Some(fm.status_band_height()), ); self.menu_buffer.set_metrics_and_size( &mut self.font_system, Metrics::new(fm.menu_font_size(), fm.menu_line_height()), Some(MENU_MAX_WIDTH), Some(height), ); let drop_metrics = Metrics::new(fm.mb_drop_font_size(), fm.mb_drop_line_height()); self.mb_buffer.set_metrics_and_size( &mut self.font_system, drop_metrics, Some(MB_DROP_MAX_WIDTH), Some(height), ); self.completion_buffer.set_metrics_and_size( &mut self.font_system, drop_metrics, Some(MB_DROP_MAX_WIDTH), Some(height), ); code_layout_changed } /// Reflow after a dynamic painter-geometry input changes: gutter /// mode/digit width or minimap presence. The caller captures /// `caret_was_painted` against the old geometry before mutating /// that input. Resize the buffer first, shape once at the final /// clip, normalize the visual residual, and re-follow only a caret /// that was actually painted. A viewport is returned only when /// that settling changed the source range. fn reflow_dynamic_code_geometry(&mut self, caret_was_painted: bool) -> Option { if self.sync_buffer_dimensions() { self.reshape(); if caret_was_painted { self.ensure_caret_painted(); } } else { self.request_redraw(); } self.current_buffer_id .and_then(|buffer_id| self.viewport_send_if_changed(buffer_id)) } /// Whether every face the shipped attribute set can select for /// `family` is monospaced (framing Q#F6): the four queries /// reachable through the base `Attrs` — normal, bold, italic, and /// bold-italic, all at normal stretch. fontdb's style matching /// would otherwise let a closer-weight proportional sibling win /// bold/italic text even when the normal query resolved a valid /// monospaced face, silently under-sizing gutter and menu /// advance geometry. fn family_is_monospace_everywhere(&self, family: &str) -> bool { let db = self.font_system.db(); [ (fontdb::Weight::NORMAL, fontdb::Style::Normal), (fontdb::Weight::BOLD, fontdb::Style::Normal), (fontdb::Weight::NORMAL, fontdb::Style::Italic), (fontdb::Weight::BOLD, fontdb::Style::Italic), ] .into_iter() .all(|(weight, style)| { db.query(&fontdb::Query { families: &[fontdb::Family::Name(family)], weight, stretch: fontdb::Stretch::Normal, style, }) .and_then(|id| db.face(id)) .is_some_and(|face| face.monospaced) }) } /// Apply a `FontFacts` preference wholesale — framing Q#F6's one /// transaction. Fail-closed wire validation first (this is /// deserialized protocol input; the daemon-side Lua check is a UX /// courtesy, not a trust boundary), then: record the actual /// painted-caret decision, resolve the family (four-style /// monospace gate, total fallback to the sanitized default), /// derive metrics + the measured advance, re-metric/re-size all /// seven buffers, drop the string-equality status caches, reshape /// at the retained scroll, settle the drawable width, re-follow a /// formerly painted caret (or only re-normalize an intentionally /// caret-free viewport), and invalidate dependent layout. No /// frame is submitted between the passes, and no atlas action is /// needed — the per-frame `atlas.trim()` clears `glyphs_in_use`, /// making old-font glyphs eligible for later LRU-style eviction /// under allocation pressure. fn apply_font_facts(&mut self, family: Option<&str>, size_centi_px: Option) { if let Some(size) = size_centi_px && !FONT_SIZE_CENTI_PX_RANGE.contains(&size) { // 0 would panic `Buffer::set_metrics`; huge values produce // pathological metrics/allocations. Reject the WHOLE // message: current state kept, nothing re-shaped. eprintln!( "pmacs-gpu: ignoring FontFacts with out-of-range size {size} \ (allowed {}..={} hundredths of a logical px)", FONT_SIZE_CENTI_PX_RANGE.start(), FONT_SIZE_CENTI_PX_RANGE.end(), ); return; } let caret_was_painted = self.caret_painted_in_code_clip(); let resolved = match family { None => self.font_defaults.default_family.clone(), Some(requested) if self.family_is_monospace_everywhere(requested) => { requested.to_owned() } Some(requested) => { // Deterministic frontend fallback, never round-tripped // back — the daemon never learns resolution outcomes. eprintln!( "pmacs-gpu: font family {requested:?} is unavailable or not \ monospaced across its normal/bold/italic/bold-italic \ queries; falling back to {:?}", self.font_defaults.default_family ); self.font_defaults.default_family.clone() } }; #[allow(clippy::cast_precision_loss)] // size <= 7200 is exact in f32 let scale = size_centi_px.map_or(1.0, |size| size as f32 / 100.0 / BASE_CODE_FONT_SIZE); // The measure pass (framing Q#F6): a fixed ASCII probe in the // resolved family at the new code metrics, independent of // document contents. The NORMAL-face advance becomes // authoritative for gutter geometry, and the selected/default // ratio scales the const-based fallbacks — the default family // is ratio 1 by construction, so never-set/reset stays // byte-identical. let code_metrics = Metrics::new(BASE_CODE_FONT_SIZE * scale, BASE_CODE_LINE_HEIGHT * scale); let selected_advance = probe_mono_advance(&mut self.font_system, &resolved, code_metrics); let resolved_is_default = resolved == self.font_defaults.default_family; let advance_ratio = if resolved_is_default { 1.0 } else { let default_advance = probe_mono_advance( &mut self.font_system, &self.font_defaults.default_family, code_metrics, ); match (selected_advance, default_advance) { (Some(selected), Some(default)) if selected > 0.0 && default > 0.0 => { selected / default } _ => 1.0, } }; self.resolved_family = resolved; self.fm = FontMetrics { scale, advance_ratio, }; // The default family already has an exact, pre-preference // geometry path: a shaped glyph when present, otherwise the // ratio-scaled baseline constant. Keep using it so resetting // to `(None, None)` is bit-identical to never-set; averaging a // ten-cell f32 run can differ by one ulp. Alternate families // need the measured normal-face advance because their cell // width is not encoded in the baseline. self.measured_mono_advance = if resolved_is_default { None } else { selected_advance }; // Every row-oriented surface stays one row across the metric // transaction, including the two status buffers (Q#SL10). self.status_buffer .set_wrap(&mut self.font_system, Wrap::None); self.status_left_buffer .set_wrap(&mut self.font_system, Wrap::None); self.menu_buffer.set_wrap(&mut self.font_system, Wrap::None); self.mb_buffer.set_wrap(&mut self.font_system, Wrap::None); self.completion_buffer .set_wrap(&mut self.font_system, Wrap::None); // Metrics + current dimensions atomically on all seven. self.sync_buffer_dimensions(); // Colors and family are attrs embedded in the status buffers. // `None` forces the next frame to install and shape rich runs. self.status_runs = None; self.status_left_runs = None; // Attrs-bearing reshape at the retained scroll (reshape // normalizes it against the FINAL family/metrics/dims). self.reshape(); // Settle the drawable code width: `text_left` depends on the // measured advance via the gutter, so re-derive and reshape // once more if it moved. No frame is submitted between passes. let width_before = self.buffer.size().0; self.sync_buffer_dimensions(); if self.buffer.size().0 != width_before { self.reshape(); } if caret_was_painted { self.ensure_caret_painted(); } else { // Preserve the user's scroll — a font change must never // turn an overscan-only caret into a snap-back. self.normalize_code_scroll(); } // Dependent layout: the minimap vertex cache keys on // (generation, size, scroll_top) and would miss a pure // metrics change; hit runs rebuild lazily (reshape dirtied // them). self.minimap_cache = None; self.request_redraw(); } fn resize(&mut self, width: u32, height: u32) -> Option { // The same painted-before policy as the font transaction // (framing Q#F6), decided against the OLD geometry: narrowing // must not strand a stationary caret in a new wrap, and // widening an intentionally caret-free viewport only // normalizes its retained scroll. let caret_was_painted = self.caret_painted_in_code_clip(); self.config.width = width; self.config.height = height; if let Some(surface) = &self.surface { surface.configure(&self.device, &self.config); } self.viewport .update(&self.queue, Resolution { width, height }); // All seven buffers through the shared dimension helper. self.sync_buffer_dimensions(); // A taller/shorter window changes the visible line count, so the // slice + scoped viewport change (session S1). self.reshape(); if caret_was_painted { self.ensure_caret_painted(); } self.request_redraw(); self.current_buffer_id .and_then(|bid| self.viewport_send_if_changed(bid)) } /// Acquire the surface's current texture and render into it — the live /// windowed path. Composition lives in `render_to_view`, shared with /// the headless offscreen path (`render_offscreen`, F-014). fn render(&mut self) { let frame = { let Some(surface) = self.surface.as_ref() else { return; }; match surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(frame) | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { 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()); self.render_to_view(&view); frame.present(); } /// Render one frame to an offscreen texture and read it back as packed /// RGBA8 (`width * height * 4` bytes, row padding removed). The entry /// point for the headless render harness (F-014) and for the Vterm /// Stage 3 attach probe, which needs real composited pixels rather /// than a claim that it rendered. fn render_offscreen(&mut self) -> Vec { let width = self.config.width; let height = self.config.height; let texture = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("pmacs-gpu offscreen target"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: self.config.format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); self.render_to_view(&view); // Copy into a mappable buffer, honoring the 256-byte per-row // alignment `copy_texture_to_buffer` requires. let unpadded_bytes_per_row = width * 4; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align; let readback = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("pmacs-gpu readback"), size: u64::from(padded_bytes_per_row) * u64::from(height), usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, }); let mut encoder = self .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pmacs-gpu readback encoder"), }); encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: &texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &readback, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded_bytes_per_row), rows_per_image: Some(height), }, }, wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, ); self.queue.submit(std::iter::once(encoder.finish())); let slice = readback.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |result| { let _ = tx.send(result); }); self.device .poll(wgpu::PollType::wait_indefinitely()) .expect("poll readback"); rx.recv().expect("map channel").expect("map readback"); let mapped = slice.get_mapped_range(); let mut pixels = Vec::with_capacity((unpadded_bytes_per_row * height) as usize); for row in 0..height { let start = (row * padded_bytes_per_row) as usize; pixels.extend_from_slice(&mapped[start..start + unpadded_bytes_per_row as usize]); } drop(mapped); readback.unmap(); pixels } /// Compose and submit one frame into `view`. Window-agnostic — shared /// by the live surface path (`render`) and the headless offscreen path /// (`render_offscreen`, F-014). No surface acquire, no `present`. #[allow(clippy::too_many_lines)] // linear per-frame GPU sequence + optional timing. fn render_to_view(&mut self, view: &wgpu::TextureView) { let frame_start = debug_frame().then(std::time::Instant::now); self.refresh_status_line(); self.refresh_menu_buffer(); // UX gutter: reshape the line-number layer to the current scroll // (no-op when the gutter is off). self.refresh_gutter_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(); // Arc 1a Q#C5 — the completion dropdown quads (bg + selection), // a layer over the code anchored at the popup's byte anchor. // `refresh_completion_buffer` first so the width measurement in // `completion_dropdown_vertex_bytes` is current. self.refresh_completion_buffer(); let completion_vertices = self.completion_dropdown_vertex_bytes(); let completion_vertex_count = (completion_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let completion_bg_buffer = self .completion_bg_vertex_buffer .upload( &self.device, &self.queue, "pmacs-gpu completion dropdown", &completion_vertices, ) .cloned(); // Vterm Stage 3 — terminal mode replaces every document paint // batch. Document decoration washes, squiggles, caret, minimap, // and gutter describe a rope this window is not showing; the // status band and the popup layers above it stay, because they // are buffer-independent chrome the daemon still drives. let terminal_mode = self.terminal.is_some(); // 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 = if terminal_mode { self.terminal_quad_vertex_bytes() } else { 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 = if terminal_mode { self.terminal_squiggle_vertex_bytes() } else { 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 = if terminal_mode { self.terminal_cursor_vertex_bytes() } else { 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 empty_minimap: Vec = Vec::new(); let minimap_vertices = if terminal_mode { &empty_minimap } else { &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 from the true full shaped width. An over-wide // custom prefix may put this origin left of the surface; bounds // clip it while the protected suffix remains pinned. let status_width = self .status_buffer .layout_runs() .map(|run| run.line_w) .fold(0.0_f32, f32::max); let status_left = self.config.width as f32 - STATUS_TEXT_PAD - status_width; let status_top = text_area_bottom(self.config.height, self.fm) + (self.fm.status_band_height() - self.fm.status_line_height()) / 2.0; // UX gutter: the code's left origin (past the gutter) and the // main-text clip-left. Computed here as locals — calling `self.*` // inside the `prepare` args would conflict with its `&mut` borrows. let text_left = self.text_left(); // Hoisted for the same borrow reason as the colors below: the // terminal areas are built inside a `&mut self.*` argument list. let mono_advance = self.mono_advance(); let gutter_clip_left = if self.line_numbers.is_on() { text_left.floor() as i32 } else { 0 }; // Themes Q#TH5/Q#TH9: resolve the face-driven colors before // the prepare call — its `&mut self.*` field borrows preclude // method calls on `self` inside the argument list. let readout_color = self .modeline_face_colors() .map_or(Color::rgb(168, 168, 180), |(_, text)| text); let left_color = self.status_left_color(); let gutter_color = self.face_fg_or("ui.gutter", Color::rgb(120, 120, 135)); // Vterm Stage 3 — the document code layer is dropped entirely // in terminal mode; terminal glyphs draw from their own // per-run layer below, positioned at cell origins. let code_areas: Vec