//! pmacs-gpu — GPU/GUI frontend for pmacs. //! //! User-facing invocation is strict: //! //! - `pmacs-gpu --attach ` directly attaches to an //! already-running daemon and never starts or replaces it. //! - The root `pmacs --gpu` broker invokes a hidden managed mode that connects //! first, starts the supplied daemon only for an absent/refused socket, and //! creates the window only after protocol and capability negotiation. //! - Headless probe modes exercise the same direct and managed production //! connectors for acceptance without requiring a display. //! //! An attached frontend imports the daemon's `BufferSnapshot` into a local //! loro replica, sends a `Viewport` back to request scoped styling, and //! consumes the `StyleSpans` stream. Live `CrdtOp` updates apply to the //! replica; subsequent `StyleSpans` frames re-style it. //! //! 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 math_layout; mod math_parse; mod terminal; use std::collections::HashMap; use std::ffi::OsString; use std::os::unix::ffi::OsStrExt; 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, MinibufferRow, Modifiers, MouseButton as ProtocolMouseButton, MouseKind as ProtocolMouseKind, PointerKind, SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TEXT_INPUT_MIN_VERSION, TerminalFrame, UnderlineStyle, cell::{Color as CellColor, Style as CellStyle}, is_builtin_pair_char, is_modeline_face_name, panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload}, }; use wgpu::MultisampleState; use winit::application::ApplicationHandler; use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent}; use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::keyboard::{Key, NamedKey}; use winit::window::{Window, WindowId}; use crate::attach::{AttachClient, AttachEvent, InitialTargetPaths}; 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 } /// The panel divider strip's thickness (Stage 2 framing §5.3). /// /// Scaled like the status band because it is row chrome, not a fixed /// surface inset. The whole strip is both painted and hit-tested, so /// paint geometry and drag geometry cannot drift apart. fn divider_height(self) -> f32 { BASE_DIVIDER_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"); // Inline-math slice (Q#MS7): the math glyphs draw through // cosmic-text, so the same bytes the layout engine measures must // resolve as a family here — the F8b pin. Proportional, so the // same-family monospace filter below cannot touch it. db.load_font_source(fontdb::Source::Binary(std::sync::Arc::new( math_layout::LATIN_MODERN_MATH, ))); 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 family name the bundled math font resolves to in fontdb; the /// draw pass pins `Attrs` to it so drawn advances come from the same /// face layout measured (framing F8b). const MATH_FONT_FAMILY: &str = "Latin Modern Math"; /// The Q#MS10 fit budget at the given code metrics, derived from the /// bundled code face's baseline placement (the framing's pinned rule). /// A custom `set_font` family shifts the painted baseline slightly; v0 /// accepts that — boxes draw against the real shaped baseline, so only /// the fit margin is approximate. fn math_code_budget(fm: FontMetrics) -> (f32, f32) { ttf_parser::Face::parse(JETBRAINS_MONO, 0).map_or((0.0, 0.0), |face| { math_layout::line_box_budget(&face, fm.code_font_size(), fm.code_line_height()) }) } /// 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]; /// Math ink (Q#MS6): the plain code text color, as glyph color for /// the mini-buffers and quad rgba for the fraction rule. Colour-by- /// context is the parent arc's deferred Q#IM2. const MATH_INK_COLOR: Color = Color::rgb(230, 230, 235); const MATH_INK_RGBA: [f32; 4] = [230.0 / 255.0, 230.0 / 255.0, 235.0 / 255.0, 1.0]; /// Line-height factor for a math glyph's mini-buffer: roomy enough /// that a lone glyph's ascender/descender never clips against the /// buffer's own line box. Positioning ignores it — the `TextArea` top /// is set from the mini-buffer's SHAPED `line_y`, so the glyph's /// baseline lands exactly where layout put it. const MATH_GLYPH_LINE_FACTOR: f32 = 2.0; /// 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; /// Panel divider (Stage 2 framing §5.3, decided open item): the rule /// between the document and an installed panel band, at scale 1.0. /// /// A 1-2 px rule is adequate decoration but too fragile as a drag target; /// 4 px still reads as a rule while giving the pointer something to grab. const BASE_DIVIDER_HEIGHT: f32 = 4.0; /// Fallback fill for the divider strip when no `ui.divider` face is set. const DIVIDER_RGBA: [f32; 4] = [0.28, 0.28, 0.36, 1.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]; const CONNECTING_TEXT: &str = "(connecting...)"; /// 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, PartialEq, Eq)] enum Mode { /// Print CLI help without initializing winit or wgpu. Help, /// Print package and protocol versions without initializing winit or wgpu. Version, /// `pmacs-gpu --attach `: strict direct attach to an existing daemon. Attach { socket: PathBuf }, /// Hidden root-broker entry: connect or start the supplied daemon before /// creating the window. ManagedAttach { socket: PathBuf, daemon_executable: PathBuf, initial_target: Option, }, /// `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 }, /// Hidden display-less acceptance seam for managed daemon lifecycle. HeadlessManagedProbe { socket: PathBuf, report: PathBuf, daemon_executable: PathBuf, initial_target: Option, }, } /// 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 = match parse_args(&std::env::args_os().skip(1).collect::>()) { Ok(mode) => mode, Err(error) => { eprintln!("pmacs-gpu: {error}\n\n{GPU_USAGE}"); std::process::exit(2); } }; match &mode { Mode::Help => { println!("{GPU_USAGE}"); return; } Mode::Version => { println!( "pmacs-gpu {} (protocol v{})", env!("CARGO_PKG_VERSION"), pmacs_protocol::PROTOCOL_VERSION ); return; } Mode::HeadlessProbe { socket, report } => { std::process::exit(run_headless_probe(socket, report)); } Mode::HeadlessManagedProbe { socket, report, daemon_executable, initial_target, } => { std::process::exit(run_headless_managed_probe( socket, report, daemon_executable, initial_target.clone(), )); } Mode::Attach { .. } | Mode::ManagedAttach { .. } => {} } let event_loop = EventLoop::::with_user_event() .build() .expect("create winit event loop"); let proxy = event_loop.create_proxy(); let (attach_client, pending_events) = if let Mode::ManagedAttach { socket, daemon_executable, initial_target, } = &mode { match attach::connect_managed_with_target( socket, daemon_executable, initial_target.clone(), proxy.clone(), ) { Ok(mut managed) => { let pending = managed .client .take_initial_message() .map(|message| vec![AppEvent::Attach(AttachEvent::Message(Box::new(message)))]) .unwrap_or_default(); (Some(managed.client), pending) } Err(error) => { eprintln!("pmacs-gpu: managed attach failed: {error}"); std::process::exit(1); } } } else { (None, Vec::new()) }; let mut app = App { mode, proxy: Some(proxy), #[cfg(test)] test_force_non_linux: false, state: None, attach_client, pending_events, 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()); // The panel wire is part of the real client, so the probe arms it exactly // as the winit path does. Leaving it out is why nothing could exercise a // panel-hosted terminal: with no declaration the daemon has no columns, // and with no columns no panel is ever presentable. state.set_panel_wire(client.session_protocol_version()); let mut facts = ProbeFacts { session_protocol_version: client.session_protocol_version(), baseline_protocol_version: client.baseline_protocol_version(), ..ProbeFacts::default() }; if let Some((geometry_epoch, total)) = state.next_geometry_declaration(GeometryTrigger::Surface) && client .send_frontend_cell_geometry(geometry_epoch, total) .is_ok() { facts.panel_declarations += 1; } // 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); } // Quiet-observation mode. `PMACS_GPU_PROBE_OBSERVE_MS` makes the probe // send NO input and request NO resize, and observe for exactly that long // instead of stopping at its usual condition. // // This exists because the ordinary probe cannot see a frame storm: it // stops as soon as it has watched a resize land, so a session emitting a // frame every tick and one emitting three in total both satisfy it. A // fixed window over a child that produces no output turns "how many // frames did the daemon send?" into a number worth asserting on. let observe_window = std::env::var("PMACS_GPU_PROBE_OBSERVE_MS") .ok() .and_then(|value| value.parse::().ok()) .map(std::time::Duration::from_millis); // Normal probes stop only after their fixture-specific evidence arrives. // A producer fixture names the text it must paint; an input fixture uses // the latched echo observation. Keeping that choice outside this generic // runner prevents one fixture's breadcrumb from forcing another fixture // to sit on the 20-second safety deadline. let expected_frame_text = std::env::var("PMACS_GPU_PROBE_EXPECT_TEXT") .ok() .filter(|value| !value.is_empty()); // A panel fixture's evidence is text in the BAND, not in a full-window // terminal. Naming it separately keeps one fixture's breadcrumb from // satisfying another's loop exit — the leak that made a Vterm probe pass // on its safety deadline. let expected_panel_text = std::env::var("PMACS_GPU_PROBE_EXPECT_PANEL_TEXT") .ok() .filter(|value| !value.is_empty()); let quiet = observe_window.is_some(); let deadline = std::time::Instant::now() + observe_window.unwrap_or_else(|| std::time::Duration::from_secs(20)); let mut sent_input = false; let mut sent_resize = false; let mut completion_observed = 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); } // §5b — a panel message is only NOTED here; its facts // are read from the retained state AFTER // `apply_attach_message` has ruled on it. // // Reading the raw payload was a false-positive // generator: once one valid frame had landed, a // REJECTED frame — wrong family, invalid, stale // generation — still supplied the expected text while // the retained old frame supplied the rendering. The // probe would report the band showing something it does // not show. // The payload KIND is kept only to tell a real `Absent` // apart from a refusal; every fact below comes from the // accepted state. let panel_absent_payload = matches!( msg.as_ref(), InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent) ); let panel_message = matches!(msg.as_ref(), InstanceMessage::PanelFrame(_)); // The COMPLETE accepted authority, both halves. An // epoch/size triple is not enough: ordinary content, // focus, cursor and mapping-generation updates all leave // it unchanged, so accepted frames would go uncounted — // including the identical-frame/higher-generation case // this slice requires — and a fixture waiting for two // frames would wait forever. let authority_before = state .panel .presented() .cloned() .map(|frame| (frame, state.panel.mapping_generation)); state.apply_attach_message(*msg); if panel_message { let authority_after = state .panel .presented() .cloned() .map(|frame| (frame, state.panel.mapping_generation)); if authority_before != authority_after { facts.panel_frames += 1; } if let Some((frame, _)) = authority_after.as_ref() { facts.panel_rows = frame.size.rows; facts.panel_cols = frame.size.cols; facts.panel_focused = frame.focused; facts.panel_frame_text = grid_probe_text(&frame.cells); if let Some(expected) = expected_panel_text.as_deref() && facts.panel_frame_text.contains(expected) { facts.panel_text_observed = true; } } // Absence is only ever reported for an actual // `Absent`. Inferring it from `presented() == None` // turns a REFUSAL with nothing retained into "the // daemon says there is no band", which is a // different fact entirely. if panel_absent_payload { facts.panel_absent_observed = true; } } 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 facts.last_frame_text.contains(PROBE_INPUT_CHAR) { facts.input_echo_observed = true; } if !quiet && !sent_input && facts.frames >= 1 { sent_input = true; // Real child input over the real wire. let _ = client.send_key(ProtocolKey::Char(PROBE_INPUT_CHAR), Modifiers::NONE); let _ = client.send_key(ProtocolKey::Enter, Modifiers::NONE); } if !quiet && !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; } } // A panel fixture's document window is NOT a terminal — the // terminal lives in the band — so the arm above never fires // and its resize/composite evidence never arrives. The band // gets the same treatment against its own observations. if state.panel.presented().is_some() { 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 !quiet && !sent_input && facts.panel_frames >= 1 { sent_input = true; let _ = client.send_key(ProtocolKey::Char(PROBE_INPUT_CHAR), Modifiers::NONE); let _ = client.send_key(ProtocolKey::Enter, Modifiers::NONE); } if !quiet && !sent_resize && facts.panel_frames >= 2 { sent_resize = true; state.resize(700, 500); if let Some((geometry_epoch, total)) = state.next_geometry_declaration(GeometryTrigger::Surface) && client .send_frontend_cell_geometry(geometry_epoch, total) .is_ok() { facts.panel_declarations += 1; facts.panel_resized_cols = total.cols; } } if facts.panel_resized_cols > 0 && facts.panel_cols == facts.panel_resized_cols { facts.panel_observed_resized_frame = true; } } let fixture_evidence_observed = expected_frame_text.as_deref().map_or_else( || facts.input_echo_observed, |expected| facts.last_frame_text.contains(expected), ); // Do not exit merely because resize/composition happened // first: that races the fixture's required PTY evidence and // produces a self-contradictory "successful" probe report // whose later acceptance assertion must reject it. if expected_panel_text.is_some() { // The panel fixture's completion, stated in its own terms. // `panel_text_observed` alone is not enough: it would let a // pass happen before the band ever composited or the resize // round-tripped, and this acceptance is exactly about the // band being real. if !quiet && facts.panel_text_observed && facts.panel_observed_resized_frame && facts.rendered_nonuniform_frames >= 2 { completion_observed = true; break; } } else if !quiet && facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 && fixture_evidence_observed { completion_observed = true; break; } } } } let mut out = String::new(); let _ = writeln!( out, "session_protocol_version={}", facts.session_protocol_version ); let _ = writeln!( out, "baseline_protocol_version={}", facts.baseline_protocol_version ); let _ = writeln!(out, "panel_declarations={}", facts.panel_declarations); let _ = writeln!(out, "panel_frames={}", facts.panel_frames); let _ = writeln!(out, "panel_resized_cols={}", facts.panel_resized_cols); let _ = writeln!( out, "panel_observed_resized_frame={}", facts.panel_observed_resized_frame ); let _ = writeln!(out, "panel_rows={}", facts.panel_rows); let _ = writeln!(out, "panel_cols={}", facts.panel_cols); let _ = writeln!(out, "panel_focused={}", facts.panel_focused); let _ = writeln!(out, "panel_text_observed={}", facts.panel_text_observed); let _ = writeln!(out, "panel_absent_observed={}", facts.panel_absent_observed); let _ = writeln!( out, "panel_frame_text_hex={}", hex_bytes(facts.panel_frame_text.as_bytes()) ); 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, "input_echo_observed={}", facts.input_echo_observed); let _ = writeln!(out, "completion_observed={completion_observed}"); 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 } /// Exercise the real managed connector without creating a display. /// /// After the first real `BufferSnapshot`, the probe writes `phase=ready` and /// holds the session open until stdin reaches EOF. Lifecycle observations /// refresh the report while held; EOF writes `phase=complete`. #[allow( clippy::too_many_lines, reason = "one linear managed-connect and lifecycle observation probe" )] fn run_headless_managed_probe( socket: &Path, report: &Path, daemon_executable: &Path, initial_target: Option, ) -> i32 { use std::io::Read as _; use std::sync::mpsc; use std::time::{Duration, Instant}; let (connector_tx, event_rx) = mpsc::channel::(); let managed = match attach::connect_managed_with_target_and_sink( socket, daemon_executable, initial_target, move |event| connector_tx.send(event).is_ok(), ) { Ok(managed) => managed, Err(error) => { let contents = format!("phase=error\nerror={error}\n"); let _ = write_probe_report(report, &contents); eprintln!("pmacs-gpu managed probe: attach failed: {error}"); return 4; } }; let mut client = managed.client; let initial_message = client.take_initial_message(); let initial_target_ready = matches!( initial_message.as_ref(), Some(InstanceMessage::BufferSnapshot { .. }) ); let mut buffer_facts = ManagedProbeBufferFacts::default(); if let Some(message) = initial_message.as_ref() && let Err(error) = buffer_facts.observe(message) { let contents = format!("phase=error\nerror={error}\n"); let _ = write_probe_report(report, &contents); eprintln!("pmacs-gpu managed probe: {error}"); return 7; } let daemon = managed.daemon; let protocol = client.session_protocol_version(); let baseline = client.baseline_protocol_version(); let (stdin_tx, stdin_rx) = mpsc::channel(); std::thread::Builder::new() .name("pmacs-gpu managed probe stdin".into()) .spawn(move || { let mut bytes = Vec::new(); let _ = std::io::stdin().read_to_end(&mut bytes); let _ = stdin_tx.send(()); }) .expect("spawn managed probe stdin reader"); let deadline = Instant::now() + Duration::from_secs(20); let mut ready = initial_target_ready; let mut stdin_closed = false; let mut disconnect = String::new(); let mut last_reaped = false; let mut last_wait_result = None; let mut last_disconnect = String::new(); if ready && let Err(error) = write_managed_probe_report( report, "ready", protocol, baseline, &daemon, &buffer_facts, &disconnect, ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", report.display() ); return 5; } loop { if stdin_rx.try_recv().is_ok() { stdin_closed = true; } match event_rx.recv_timeout(Duration::from_millis(50)) { Ok(AttachEvent::Message(message)) => { let is_snapshot = matches!(*message, InstanceMessage::BufferSnapshot { .. }); if let Err(error) = buffer_facts.observe(&message) { let contents = format!("phase=error\nerror={error}\n"); let _ = write_probe_report(report, &contents); eprintln!("pmacs-gpu managed probe: {error}"); return 7; } if is_snapshot { ready = true; if let Err(error) = write_managed_probe_report( report, "ready", protocol, baseline, &daemon, &buffer_facts, &disconnect, ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", report.display() ); return 5; } } } Ok(AttachEvent::Disconnected(reason)) => disconnect = reason, Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Disconnected) => { if disconnect.is_empty() { "attach event channel closed".clone_into(&mut disconnect); } std::thread::sleep(Duration::from_millis(50)); } } let reaped = daemon.daemon_reaped(); let wait_result = daemon.daemon_wait_result(); if ready && (reaped != last_reaped || wait_result != last_wait_result || disconnect != last_disconnect) { if let Err(error) = write_managed_probe_report( report, "ready", protocol, baseline, &daemon, &buffer_facts, &disconnect, ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", report.display() ); return 5; } last_reaped = reaped; last_wait_result = wait_result; last_disconnect.clone_from(&disconnect); } if ready && stdin_closed { if let Err(error) = write_managed_probe_report( report, "complete", protocol, baseline, &daemon, &buffer_facts, &disconnect, ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", report.display() ); return 5; } return 0; } if !ready && Instant::now() >= deadline { let contents = format!( "phase=error\nerror=timed out waiting for BufferSnapshot\ndisconnect={disconnect}\n" ); let _ = write_probe_report(report, &contents); eprintln!("pmacs-gpu managed probe: timed out waiting for BufferSnapshot"); return 6; } } } #[derive(Default)] struct ManagedProbeBufferFacts { snapshots: u32, last_snapshot_text: String, } impl ManagedProbeBufferFacts { fn observe(&mut self, message: &InstanceMessage) -> Result<(), String> { let InstanceMessage::BufferSnapshot { crdt_snapshot, .. } = message else { return Ok(()); }; let doc = loro::LoroDoc::new(); doc.import(crdt_snapshot) .map_err(|error| format!("BufferSnapshot import failed: {error:?}"))?; self.snapshots += 1; self.last_snapshot_text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); Ok(()) } } fn hex_bytes(bytes: &[u8]) -> String { use std::fmt::Write as _; let mut encoded = String::with_capacity(bytes.len() * 2); for byte in bytes { let _ = write!(encoded, "{byte:02x}"); } encoded } fn write_managed_probe_report( report: &Path, phase: &str, protocol: u32, baseline: u32, daemon: &attach::ManagedDaemonFacts, buffer_facts: &ManagedProbeBufferFacts, disconnect: &str, ) -> std::io::Result<()> { use std::fmt::Write as _; let mut out = String::new(); let _ = writeln!(out, "phase={phase}"); let _ = writeln!(out, "session_protocol_version={protocol}"); let _ = writeln!(out, "baseline_protocol_version={baseline}"); let _ = writeln!(out, "buffer_snapshot=true"); let _ = writeln!(out, "buffer_snapshots={}", buffer_facts.snapshots); let _ = writeln!( out, "last_snapshot_hex={}", hex_bytes(buffer_facts.last_snapshot_text.as_bytes()) ); let _ = writeln!(out, "spawned_daemon={}", daemon.spawned_daemon()); let _ = writeln!( out, "daemon_pid={}", daemon.daemon_pid().unwrap_or_default() ); let _ = writeln!(out, "daemon_reaped={}", daemon.daemon_reaped()); let _ = writeln!( out, "daemon_wait_result={}", daemon.daemon_wait_result().unwrap_or_default() ); let _ = writeln!(out, "disconnect={disconnect}"); write_probe_report(report, &out) } fn write_probe_report(report: &Path, contents: &str) -> std::io::Result<()> { let mut temporary = report.as_os_str().to_os_string(); temporary.push(".tmp"); let temporary = PathBuf::from(temporary); std::fs::write(&temporary, contents)?; std::fs::rename(temporary, report) } /// Named observations the headless probe reports back to the acceptance. #[allow( clippy::struct_excessive_bools, reason = "a flat report of independently-latched observations, not a state machine" )] #[derive(Default)] struct ProbeFacts { /// The version the SESSION negotiated (this frontend's counter-offer). session_protocol_version: u32, /// The compatibility baseline the daemon advertised in `Hello`. /// /// Reported beside the negotiated version rather than instead of it: /// Stage 2B-3's whole activation claim is that these two DIFFER — the /// daemon still advertises a version every shipped frontend accepts /// while this session speaks the newer wire — and a report carrying /// only one of them cannot express that. baseline_protocol_version: u32, /// How many `Present` panel frames the daemon shipped. panel_frames: u32, /// The last `Present` band's grid, and whether it owned focus. panel_rows: u32, panel_cols: u32, panel_focused: bool, /// The band's own text, so an acceptance can prove the PTY child's output /// landed IN THE PANEL rather than in a full-window document terminal. panel_frame_text: String, /// Latched across frames: a reflow can push the breadcrumb off the last /// band, so "it arrived" and "it is still on the final band" are /// different questions and only the first is what the fixture means. panel_text_observed: bool, /// Whether an authoritative `Absent` was seen. panel_absent_observed: bool, /// The panel geometry declarations this probe sent. panel_declarations: u32, /// The band's grid after the probe's resize, and whether a band at that /// exact width was actually observed afterwards. panel_resized_cols: u32, panel_observed_resized_frame: bool, 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, /// Whether any frame carried the probe's own typed character back. /// /// Latched ACROSS frames, not read off the final one: a later geometry /// change reflows the screen, so "the echo arrived" and "the echo is /// still on the last frame" are different questions and only the first /// one is about input reaching the child. input_echo_observed: bool, disconnect: Option, } /// The character the probe types into the child. Distinct from anything the /// acceptance children print themselves, so its appearance is unambiguous. const PROBE_INPUT_CHAR: char = 'x'; /// One-line printable text of a terminal frame, for probe reporting. fn frame_probe_text(frame: &TerminalFrame) -> String { grid_probe_text(&frame.cells) } /// The same flattening for any wire cell grid, so a panel frame and a /// terminal frame are read the same way rather than by two near-copies. fn grid_probe_text(cells: &[pmacs_protocol::Cell]) -> String { let mut text = String::new(); for cell in 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 } const GPU_USAGE: &str = "\ pmacs-gpu — GPU frontend for pmacs NORMAL STARTUP: pmacs --gpu [--socket NAME|PATH] [FILE] start/reuse a daemon and open FILE ADVANCED DIRECT ATTACH: pmacs-gpu --attach attach to an existing daemon only OPTIONS: pmacs-gpu --help print this help pmacs-gpu --version print package and protocol versions"; /// Strict parser for direct, managed, and headless GPU entry points. #[allow( clippy::too_many_lines, reason = "one exact-arity parser keeps private GPU entry points visibly fail-closed" )] fn parse_args(args: &[OsString]) -> Result { fn option_like(value: &OsString) -> bool { value.as_os_str().as_bytes().starts_with(b"-") } fn reject_option_like(command: &str, operands: &[&OsString]) -> Result<(), String> { if let Some(operand) = operands.iter().find(|operand| option_like(operand)) { return Err(format!( "{command} received option-like path operand {}; prefix it with ./ if it is a path", operand.to_string_lossy() )); } Ok(()) } fn target(cwd: &OsString, path: &OsString) -> InitialTargetPaths { InitialTargetPaths { cwd: PathBuf::from(cwd), path: PathBuf::from(path), } } if let Some(flag) = args.first() && option_like(flag) && flag.to_str().is_none() { return Err("option names must be valid UTF-8".to_owned()); } match args { [flag] if flag == "--help" || flag == "-h" => Ok(Mode::Help), [flag] if flag == "--version" || flag == "-V" => Ok(Mode::Version), [flag, socket] if flag == "--attach" => { reject_option_like("--attach", &[socket])?; Ok(Mode::Attach { socket: PathBuf::from(socket), }) } [flag, socket, daemon_executable] if flag == "--managed-attach" => { reject_option_like("--managed-attach", &[socket, daemon_executable])?; Ok(Mode::ManagedAttach { socket: PathBuf::from(socket), daemon_executable: PathBuf::from(daemon_executable), initial_target: None, }) } [flag, socket, daemon_executable, marker, cwd, path] if flag == "--managed-attach" && marker == "--initial-target" => { reject_option_like("--managed-attach", &[socket, daemon_executable, cwd])?; Ok(Mode::ManagedAttach { socket: PathBuf::from(socket), daemon_executable: PathBuf::from(daemon_executable), initial_target: Some(target(cwd, path)), }) } [flag, socket, report] if flag == "--headless-probe" => { reject_option_like("--headless-probe", &[socket, report])?; Ok(Mode::HeadlessProbe { socket: PathBuf::from(socket), report: PathBuf::from(report), }) } [flag, socket, report, daemon_executable] if flag == "--headless-managed-probe" => { reject_option_like( "--headless-managed-probe", &[socket, report, daemon_executable], )?; Ok(Mode::HeadlessManagedProbe { socket: PathBuf::from(socket), report: PathBuf::from(report), daemon_executable: PathBuf::from(daemon_executable), initial_target: None, }) } [flag, socket, report, daemon_executable, marker, cwd, path] if flag == "--headless-managed-probe" && marker == "--initial-target" => { reject_option_like( "--headless-managed-probe", &[socket, report, daemon_executable, cwd], )?; Ok(Mode::HeadlessManagedProbe { socket: PathBuf::from(socket), report: PathBuf::from(report), daemon_executable: PathBuf::from(daemon_executable), initial_target: Some(target(cwd, path)), }) } [] => Err( "managed startup is provided by `pmacs --gpu`; direct use requires --attach " .to_owned(), ), [flag, ..] if flag == "--help" || flag == "-h" || flag == "--version" || flag == "-V" => { Err(format!( "{} does not accept operands", flag.to_string_lossy() )) } [flag, ..] if flag == "--attach" || flag == "--managed-attach" || flag == "--headless-probe" || flag == "--headless-managed-probe" => { Err(format!( "{} received the wrong number of operands", flag.to_string_lossy() )) } [other, ..] => Err(format!( "unrecognized argument: {}", other.to_string_lossy() )), } } /// 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, /// Test override for B4's platform decision: pretend this build is /// not Linux, so a row can drive the inert branch on a Linux host. /// /// **Needed because no CI leg runs this crate's tests off Linux.** /// Without it the off-Linux contract is asserted nowhere that /// actually executes, and a call-site `unwrap_or(Clipboard)` passes /// everything. #[cfg(test)] test_force_non_linux: bool, /// User events received before winit creates `state`. Managed attach /// starts its reader before `run_app`, so the initial snapshot may arrive /// before `resumed` on backends with a different callback order. pending_events: Vec, /// 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, } fn defer_app_event( state_ready: bool, pending: &mut Vec, event: AppEvent, ) -> Option { if state_ready { Some(event) } else { pending.push(event); None } } 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 { /// GUI 1-pre P2 — how many times [`State::render`] has been called. /// The redraw arm's only local effect, and invisible without this /// on a windowless `State`. Test-only. #[cfg(test)] render_calls: u64, // `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, /// Horizontal scroll offset of the code area, in **pixels** (Stage /// 5, framing Q#G1). /// /// Pixels rather than columns because the GPU's other viewport /// state already is (`code_scroll_residual` above), and a pixel /// offset composes with the clip rectangle without per-frame /// rounding. Column parity with the TUI is still exact: the code /// font is monospace by contract /// (`family_is_monospace_everywhere`), so `columns × advance` is a /// definition rather than an approximation. /// /// **Local viewport state, never sent.** Same category as /// `scroll_top`: no wire message and no protocol bump (§1.2). /// /// Reset to 0 on BOTH the wrap transition and a buffer snapshot /// (Q#G2) — inertness alone would let a stale offset reappear. code_scroll_left: 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, /// The icon last written to the window, so a per-motion call is a /// comparison rather than a platform round-trip. last_cursor_icon: Option, /// Stub selections for tests; see [`Self::set_test_selection`]. #[cfg(test)] test_selections: HashMap>, /// GUI Stage 1b B1: per-target, per-axis fractional wheel residual. /// Sub-tick deltas are banked here instead of being rounded away /// before routing knows where they were going. wheel_residuals: WheelResiduals, /// 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>, /// Per-shaped-line math suppression state, in lockstep with /// `line_chunk_cache`: the detected spans with the Q#MS5 gate bit /// each was built under (the line-reuse predicate's third input /// beside content and styling), and the placed boxes the draw /// pass paints into the reserved spacer rectangles. line_math_cache: Vec, /// The MATH layout engine over the bundled font, or `None` when /// the font failed to yield math metrics at startup — a hard /// error in the math path only (Q#MS7): spans render as source /// and the editor keeps running. math_engine: Option>, /// `(above_baseline, below_baseline)` budget a math box must fit /// (Q#MS10), derived by `math_layout::line_box_budget` from the /// CODE font's baseline placement. Recomputed when metrics change. math_budget: (f32, f32), /// 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, /// Dedicated text renderer for inline-math glyphs (Q#MS6): each /// `MathItem::Glyph` draws from its own mini-buffer positioned at /// layout's exact x/baseline, so an accumulated shaping advance /// can never move a glyph off its measured origin — the same /// per-run argument the terminal renderer made. math_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, /// Bottom-panel arc Stage 2B-3: this frontend's panel band — the /// retained frame, its geometry declaration, the divider drag, and the /// exhaustion latch. Present on every `State`, inert until a session /// negotiates the panel wire. panel: PanelBand, /// One shaped buffer per planned panel run. panel_text_buffers: Vec, /// Whether the negotiated session carries the panel wire at all. /// /// Keyed on the NEGOTIATED version, never the `Hello` baseline. panel_family: PanelFamily, /// Set when a font/scale transaction has invalidated the panel's /// geometry declaration, so the caller that owns the client knows to /// re-declare under a `Metrics` trigger. /// /// A flag rather than a direct send because `apply_message` cannot /// reach the attach client, and because the distinction it carries — /// `Metrics` versus `Surface` — is exactly what stops an identical /// `CellSize` from being deduped away. panel_metrics_changed: bool, /// 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, /// The band's own glyph layer. panel_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, } /// Why frame geometry is being (re-)declared (Q#BP2S1). /// /// The two arms differ in exactly one way — whether an *identical* /// [`CellSize`] still advances the epoch — and that difference is the whole /// reason the epoch is frontend-owned. Collapsing them into one call site /// reintroduces the bug option 1 was chosen to avoid. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum GeometryTrigger { /// A surface resize, or the first declaration after attach. An /// identical cell total means nothing the daemon can act on changed, /// so it is not re-declared. Surface, /// A font family, size, or scale change. The cell total may be /// **identical** while the pixels behind it are not, which is exactly /// what daemon-side value dedup cannot see — so this always advances. Metrics, } /// One `PanelResizeRows` request a drag has decided to make. #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct PanelResizeRequest { geometry_epoch: u64, panel_epoch: u64, rows: u32, } /// Which surface a pointer pixel belongs to (Q#BP16). /// /// **One authority for "does the band claim this pixel", consulted by every /// pointer handler.** Four handlers route gestures — motion, left /// press/release, right press, and wheel — and the band has to be consulted /// first in all four. When each handler decided for itself, three of them /// simply did not ask, so right-click and wheel fell through to the document /// underneath and a held left button was reported as a hover. A single /// classifier makes forgetting the band impossible to do quietly, and makes /// the routing testable without a window or a daemon. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PointerSurface { /// The divider strip: a drag handle, never a cell gesture. PanelDivider, /// A cell inside the band. PanelCell(CellCoord), /// The band's fractional right-edge remainder, or anywhere else in the /// band that maps to no cell. Distinct from `Elsewhere` because the band /// still owns the pixel — it just emits no `PanelPointer`. PanelBackground, /// Not the band: the document, the terminal, the minimap, or the chrome. Elsewhere, } /// The six wheel targets GUI Stage 1b's B1 owns a residual for. /// /// **`PointerSurface` cannot name these**, which is why this exists /// (framing §2a CORRECTION 5): that classifier resolves panel geometry /// only, and collapses the document, the terminal, the minimap and the /// chrome into a single `Elsewhere` — three of which B1 and B6 must /// keep apart. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum WheelTarget { /// A cell inside the band. Residual **per panel**, keyed by the /// panel's buffer. PanelCell { buffer: BufferId, coord: CellCoord }, /// The divider or the band's background. **The band owns the /// pixel**, so both axes are consumed — and nothing is banked. PanelChrome, /// The terminal clip. Residual **per terminal**, keyed by buffer. Terminal { buffer: BufferId, coord: CellCoord }, /// The minimap band. **Its own** residual (B6). Minimap, /// Document text. Shares its residual with [`WheelTarget::Chrome`]. Document, /// Anything else outside the band: gutter, margins, status chrome. /// **Shares the document's residual, deliberately** — a wheel that /// strays onto the gutter mid-gesture must not lose the motion. Chrome, } /// Which accumulator a [`WheelTarget`] banks into. /// /// Two targets map to `Document` on purpose, and one target maps to /// nothing at all: panel chrome consumes without banking, because a /// residual it could share with a cell would let motion over an inert /// strip complete a tick the moment the pointer entered a live one — /// **a surface-switch jump manufactured by the accumulator itself**, /// which is exactly what B1 exists to forbid. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] enum ResidualOwner { /// R2's identity: two panels are two owners. Panel(BufferId), /// R3's identity: two terminals are two owners. Terminal(BufferId), /// R5: independent of the document's, though it moves the document. Minimap, /// R4: the document's, shared with chrome. Document, } impl WheelTarget { /// The accumulator this target banks into, or `None` when it banks /// nowhere. fn residual_owner(self) -> Option { match self { Self::PanelCell { buffer, .. } => Some(ResidualOwner::Panel(buffer)), Self::Terminal { buffer, .. } => Some(ResidualOwner::Terminal(buffer)), Self::Minimap => Some(ResidualOwner::Minimap), Self::Document | Self::Chrome => Some(ResidualOwner::Document), Self::PanelChrome => None, } } } /// B1's per-owner, per-axis fractional wheel residual. /// /// **The producer 1b adds.** Before this, `apply_wheel` rounded to /// whole lines and returned on zero *before* it knew where the delta /// was going, so every sub-tick motion bound for the panel or the /// terminal was discarded by a decision taken upstream of routing. /// /// Identity has two halves. The first is the key: a panel residual is /// keyed to *that* panel, so a gesture that crosses from panel A to /// panel B does not spend A's bank on B (R2, and R3 for terminals). /// The second is **disposal** — a residual keyed to a surface that goes /// away must go away with it, or it is spent on whatever later takes /// that identity. /// /// **Disposal is implemented at both teardowns**, because the key /// alone cannot see a surface closed and REOPENED on the same buffer: /// the successor carries the same `BufferId`, so nothing distinguishes /// it from the surface the user was actually scrolling. /// `PanelFramePayload::Absent` clears the panel banks and /// `exit_terminal_mode` clears the terminal ones. #[derive(Debug, Default)] struct WheelResiduals { /// `(owner) -> (x, y)` in fractional ticks, each in `(-1.0, 1.0)`. banks: HashMap, } impl WheelResiduals { /// Bank `(dx, dy)` fractional ticks for `owner` and return the whole /// ticks that fall out, keeping the remainder. /// /// `trunc`, not `round`: rounding would spend a half-tick that was /// never delivered and leave a negative remainder behind. fn accumulate(&mut self, owner: ResidualOwner, dx: f32, dy: f32) -> (i64, i64) { let bank = self.banks.entry(owner).or_insert((0.0, 0.0)); bank.0 += dx; bank.1 += dy; let ticks_x = bank.0.trunc(); let ticks_y = bank.1.trunc(); bank.0 -= ticks_x; bank.1 -= ticks_y; (ticks_x as i64, ticks_y as i64) } /// Drop the document's bank (shared with chrome) — R4's reset. fn clear_document(&mut self) { self.banks.remove(&ResidualOwner::Document); } /// Drop the minimap's bank — R5's reset, separate from R4's because /// B6 gives the minimap its own accumulator and a single combined /// clear would let one omission hide behind the other. fn clear_minimap(&mut self) { self.banks.remove(&ResidualOwner::Minimap); } /// Drop every panel bank. Panel chrome consumes both axes and must /// leave nothing that could combine with cell input later, and a /// panel that goes away takes its bank with it (B1's disposal). fn clear_panels(&mut self) { self.banks .retain(|owner, _| !matches!(owner, ResidualOwner::Panel(_))); } /// Drop every terminal bank — the same disposal, for the surface /// with the same problem. `Terminal(BufferId)` distinguishes /// terminal A from terminal B, but not leaving a terminal and /// re-entering **the same** one, where the bank's key is unchanged. fn clear_terminals(&mut self) { self.banks .retain(|owner, _| !matches!(owner, ResidualOwner::Terminal(_))); } #[cfg(test)] fn bank_of(&self, owner: ResidualOwner) -> Option<(f32, f32)> { self.banks.get(&owner).copied() } } /// A live divider drag (Q#BP15a, parent acceptance 47). #[derive(Clone, Copy, Debug)] struct PanelDrag { /// Presentation the gesture started against. A drag that outlives its /// panel is dropped rather than applied to the successor. panel_epoch: u64, /// Geometry declaration the gesture is measured against. geometry_epoch: u64, /// Rows the panel had when the drag started. start_rows: u32, /// Pointer y where the drag started, in surface pixels. start_y: f32, /// Last row count actually sent, so a drag that re-crosses the same /// row boundary does not re-send it. sent_rows: u32, } /// §5b — which panel family this session negotiated. /// /// **One value, derived once from `session_protocol_version`, used for /// BOTH payload acceptance and pointer production.** Deriving the two /// independently is how a frontend ends up accepting one family while /// producing the other, which is a bypass with extra steps: the peer /// would be speaking v25 inbound and v24 outbound and neither side /// could tell. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] enum PanelFamily { /// Below `PANEL_MIN_VERSION`: no panel at all. #[default] Unsupported, /// v21–v24: `Present` in, `PanelPointer` out. Legacy, /// v25 and later: `PresentMapped` in, `PanelPointerMapped` out. Mapped, } impl PanelFamily { /// Classify a negotiated session version. /// /// Read from the NEGOTIATED version, never the `Hello` baseline: /// that stays at the compatibility floor forever, so reading it /// would leave the band permanently dark. fn from_session_version(session_protocol_version: u32) -> Self { if session_protocol_version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION { Self::Mapped } else if session_protocol_version >= PANEL_MIN_VERSION { Self::Legacy } else { Self::Unsupported } } /// Whether a panel exists on this session at all. fn carries_panel(self) -> bool { !matches!(self, Self::Unsupported) } } /// The GPU frontend's half of the bottom panel (Q#BP15, Q#BP15a, Q#BP16). #[derive(Default)] struct PanelBand { /// §5b — the mapping generation the retained frame was published /// at, echoed by every `PanelPointerMapped` this frontend sends. /// /// Moves ATOMICALLY with `frame`: a generation naming a frame that /// was never installed would be authority for something the user /// cannot see. /// /// **Nondecreasing, and NOT cleared by `Absent`** — a frame delayed /// across a hide must not roll this frontend's authority backward. mapping_generation: Option, /// The last **valid** frame received, retained until an authoritative /// `Absent`. /// /// Silence is not absence: the daemon must send `Absent` explicitly on /// close *and* on hide, and until it does, this is what paints. An /// invalid frame is rejected whole and leaves this untouched. frame: Option, /// This frontend's monotonic geometry declaration id. `0` means never /// declared, which the wire rejects. geometry_epoch: u64, /// The cell total behind `geometry_epoch`, for the `Surface` dedup. declared: Option, /// The advance the declaration was computed with, retained so painting /// and hit-testing resolve cells with the **same** number the daemon's /// column count was derived from. /// /// Caching it is not an optimization. The declaration needs /// `&mut FontSystem` to shape its probe, so a `&self` painter cannot /// re-derive it and would reach for `mono_advance` — which is /// document-dependent. The declared grid, the painted grid, and the /// hit-tested grid would then be three different grids, and a test that /// asserted only the declaration would not see it. One value behind the /// declaration makes all three agree *by construction*. declared_advance: Option, /// Terminal exhaustion latch (framing §3.1). /// /// Once set: no further declaration is sent, and no retained frame /// paints or hit-tests **however well its epoch still matches**. The /// latch is what stops an old `Present` from resurrecting a band under /// geometry this frontend has disowned; only a fresh session clears /// it, because only a fresh session builds a fresh `PanelBand`. exhausted: bool, /// Live divider drag. One pointer, one gesture. drag: Option, /// Whether a left-button gesture that started INSIDE the band is still /// held. Mirrors `pointer_drag_active` for the document: without it a /// motion event cannot tell a hover from a drag, so `Drag(Left)` is never /// emitted and panel selection cannot work at all. pointer_held: bool, /// Last cell a panel pointer gesture reported, so sub-cell motion does /// not become pixel-rate wire traffic. Reset on every press and release, /// because the first drag after a press must reach the daemon even at the /// cell the press landed on. last_pointer_cell: Option, /// Where the live gesture last legitimately pointed **inside content**, /// used to normalize a release that lands on chrome or outside the band /// (parent 48 R-c2). /// /// **Deliberately NOT `last_pointer_cell`.** That field is a wire /// DEDUPE BASELINE and is cleared on press precisely so the first drag /// after a press reaches the daemon; storing the press cell there would /// suppress it. This one is a TERMINATION FALLBACK with a different /// lifetime, and `panel_motion_is_new` never consults it. gesture_last_content_cell: Option, /// §5b G9b — the mapping generation `last_pointer_cell` was measured /// under, so the dedupe re-arms across a mapping change. /// /// Comparing the cell alone EATS the first motion after the mapping /// moves: the pointer has not travelled, but the cell now denotes /// different text, and that is exactly the motion the daemon needs. /// `None` for a legacy session, where it is constant and the dedupe /// behaves as it always did. last_pointer_generation: Option, /// Whether the pointer is currently over the divider strip, which /// decides the `RowResize` cursor icon. hover_divider: bool, /// Cell-space paint data derived from `frame`, rebuilt on receipt so /// the render path never re-derives it per frame. plan: Option, } impl PanelBand { /// **The** single derivation of "is there a band on screen right now". /// /// Every consumer goes through this: the band inset the three /// boundaries are computed from, the painter, the hit-tester, and the /// drag. 2B-2's review found that two derivations of one panel /// predicate is precisely how the renderer and the durable state come /// to disagree, so there is exactly one here too. /// /// Three conditions, closing three different holes: /// /// * a retained valid frame exists (silence retains, `Absent` clears); /// * its `geometry_epoch` matches the current declaration — after a /// new declaration is sent, an older retained frame neither paints /// nor accepts input until a matching `Present` arrives (parent 41); /// * the exhaustion latch is clear. fn presented(&self) -> Option<&PanelFrame> { if self.exhausted { return None; } self.frame .as_ref() .filter(|frame| frame.geometry_epoch == self.geometry_epoch) } } /// 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 whichever /// minibuffer variant this session's negotiated version carries, when /// its `prompt` was `Some`. The prompt+input draw in the bottom band /// with a caret; `rows` (a windowed slice) feed the dropdown. /// /// **One local shape for two wire variants.** A `>= 23` daemon sends /// `MinibufferPromptRows` with per-row details; a `12..=22` daemon sends /// the frozen `MinibufferPrompt` with bare strings, which land here as /// rows whose `detail` is `None`. Both are live: this binary offers its /// own `PROTOCOL_VERSION` only when the daemon advertises the current /// baseline, and echoes an older baseline verbatim — so an older daemon /// still negotiates an older session, and the legacy arm is reachable /// rather than dead code. #[derive(Clone, Debug, PartialEq)] struct MinibufferLocal { prompt: String, input: String, cursor: u32, rows: 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.session_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.session_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.session_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.session_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.session_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}"), } } /// Ship a `FrontendCellGeometry` if this trigger calls for one /// (Q#BP15a). /// /// The decision is `State`'s; only the send is here, because only this /// side owns the attach client. Gated on the NEGOTIATED session /// version — the `Hello` baseline stays at the compatibility floor /// permanently, so gating on it would leave the band dark forever. fn flush_panel_geometry(&mut self, trigger: GeometryTrigger) { let Some(client) = self.attach_client.as_ref() else { return; }; if client.session_protocol_version() < PANEL_MIN_VERSION { return; } let Some(state) = self.state.as_mut() else { return; }; let Some((geometry_epoch, total)) = state.next_geometry_declaration(trigger) else { return; }; if let Err(e) = client.send_frontend_cell_geometry(geometry_epoch, total) { eprintln!("pmacs-gpu: send_frontend_cell_geometry failed: {e}"); } } /// The panel cell a pixel is over, if the band can take a gesture at all. fn panel_pointer_hit(&self, x: f64, y: f64) -> Option<(u64, u64, BufferId, CellCoord)> { let client = self.attach_client.as_ref()?; if client.session_protocol_version() < PANEL_MIN_VERSION { return None; } let state = self.state.as_ref()?; let frame = state.panel.presented()?; let coord = state.panel_hit_test(x as f32, y as f32)?; Some(( frame.geometry_epoch, frame.panel_epoch, frame.buffer_id, coord, )) } /// Ship one panel gesture at `(x, y)`, reporting whether the band claimed /// it. fn send_panel_pointer_at( &mut self, x: f64, y: f64, kind: ProtocolMouseKind, mods: Modifiers, ) -> bool { let Some((geometry_epoch, panel_epoch, buffer_id, coord)) = self.panel_pointer_hit(x, y) else { return false; }; // §5b — production reads the SAME negotiated family as // acceptance. Deriving them separately is how a frontend ends // up accepting one family and producing the other. self.send_panel_gesture(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods); true } /// Ship one panel gesture in whichever family this session /// negotiated (§5b). /// /// The single place that chooses, so acceptance and production /// cannot disagree. A mapped session with no retained generation /// sends NOTHING rather than falling back to the legacy variant: /// falling back would be the frontend half of the bypass, and the /// daemon would refuse it anyway. fn send_panel_gesture( &self, geometry_epoch: u64, panel_epoch: u64, buffer_id: pmacs_protocol::BufferId, coord: CellCoord, kind: ProtocolMouseKind, mods: Modifiers, ) { let (Some(client), Some(state)) = (self.attach_client.as_ref(), self.state.as_ref()) else { return; }; let sent = match state.panel_family { PanelFamily::Unsupported => return, PanelFamily::Legacy => { client.send_panel_pointer(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods) } PanelFamily::Mapped => { let Some(mapping_generation) = state.panel.mapping_generation else { return; }; client.send_panel_pointer_mapped( geometry_epoch, panel_epoch, buffer_id, coord, kind, mods, mapping_generation, ) } }; if let Err(e) = sent { eprintln!("pmacs-gpu: send_panel_pointer failed: {e}"); } } /// Ship a panel gesture at an explicitly chosen cell. /// /// Used for a release, whose cell may be the last one reported rather than /// the one under the pointer: a panel selection drag routinely ends past /// the band's edge, and dropping that release leaves the daemon holding a /// button down forever. The terminal path drops such a release; the panel /// must not. fn send_panel_pointer_at_cell( &mut self, coord: Option, kind: ProtocolMouseKind, mods: Modifiers, ) -> bool { let Some(client) = self.attach_client.as_ref() else { return false; }; if client.session_protocol_version() < PANEL_MIN_VERSION { return false; } let Some(state) = self.state.as_ref() else { return false; }; let Some(frame) = state.panel.presented() else { return false; }; let Some(coord) = coord else { return false; }; // §5b — through the one family-aware sender, so this site // cannot drift from the other into producing the wrong variant. let (geometry_epoch, panel_epoch, buffer_id) = (frame.geometry_epoch, frame.panel_epoch, frame.buffer_id); self.send_panel_gesture(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods); true } /// Advance a live divider drag, sending `PanelResizeRows` only when the /// requested row count actually changes. /// /// Row counts, never pixels: the daemon clamps by `window.min-height`, /// so the frontend's job is to name the rows the pointer is asking for, /// not to enforce the floor itself. fn advance_panel_drag(&mut self, y: f64) { let Some(client) = self.attach_client.as_ref() else { return; }; if client.session_protocol_version() < PANEL_MIN_VERSION { return; } let Some(state) = self.state.as_mut() else { return; }; let Some(request) = state.panel_drag_request(y as f32) else { return; }; match client.send_panel_resize_rows( request.geometry_epoch, request.panel_epoch, request.rows, ) { Ok(()) => state.note_panel_drag_sent(request.rows), Err(e) => eprintln!("pmacs-gpu: send_panel_resize_rows 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.session_protocol_version() < 11 { return; } if let Err(e) = client.send_menu_pointer(index, invoke) { eprintln!("pmacs-gpu: send_menu_pointer failed: {e}"); } } fn dispatch_app_event(&mut self, event: AppEvent) { let state = self .state .as_mut() .expect("app events dispatch only after state initialization"); 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(); // Q#BP2S1 — a font or scale transaction arrives as a // message, so its geometry re-declaration is flushed here. // `Metrics` rather than `Surface` on purpose: the cell total // may be IDENTICAL while the pixels behind it are not, and // the `Surface` dedup would drop exactly that case. if self .state .as_mut() .is_some_and(State::take_panel_metrics_changed) { self.flush_panel_geometry(GeometryTrigger::Metrics); } 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)"); } } } /// Perform [`LifecycleRoute::Resize`]. `width`/`height` arrive /// already clamped away from zero by the router. fn apply_resize(&mut self, width: u32, height: u32) { let vp = self .state .as_mut() .and_then(|state| state.resize(width, height)); 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(); // Q#BP15a — and so is the panel's whole-frame capacity. An // identical cell total is not re-declared: nothing the // daemon can act on changed. self.flush_panel_geometry(GeometryTrigger::Surface); } /// Perform [`PointerRoute::Moved`]. `x`/`y` are the physical /// pointer position winit reported. #[allow(clippy::too_many_lines)] // one linear gesture pipeline; splitting hides the order. fn apply_cursor_moved(&mut self, x: f64, y: f64) { let Some(state) = self.state.as_mut() else { return; }; state.pointer_pos = Some((x, 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() { // No icon application here, deliberately. The menu's icon is // settled when the menu OPENS (`MenuPrompt`), and motion // inside an open menu changes no ownership — a call here // would be a second writer that no row could distinguish // from the first. let hit = state.menu_hit(x, 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; } // Bottom panel Stage 2B-3 — the band is consumed BEFORE // the terminal and document paths. It sits below // `document_text_bottom`, so a band pixel cannot hit the // document grid, but ordering it first is what makes that a // stated rule rather than a consequence of the arithmetic. if state.panel.drag.is_some() { self.advance_panel_drag(y); return; } let surface = state.classify_pointer_surface(x as f32, y as f32); state.set_panel_divider_hover(surface == PointerSurface::PanelDivider); // **Applied on every motion, not only when divider hover flips.** // B5's I-beam changes as the pointer crosses the gutter or the // text's right edge, neither of which touches `hover_divider`; // gating on that flag would leave the icon stale for exactly the // transitions B5 is about. `apply_panel_cursor_icon` writes only // when the icon actually changed, so this costs no extra // `set_cursor` calls. state.apply_panel_cursor_icon(); match surface { PointerSurface::PanelDivider | PointerSurface::PanelBackground => return, PointerSurface::PanelCell(coord) => { // A held left button makes this a `Drag(Left)`, not a // `Move`. That distinction is the whole of panel // selection: `Move` never focuses or claims, while // every non-`Move` gesture activates the panel first, // so reporting a drag as a hover makes a selection // drag silently do nothing. let kind = state.panel_motion_kind(); let is_chrome = state.panel_cell_is_chrome(coord); if !is_chrome { let state = self.state.as_mut().expect("checked above"); state.panel.gesture_last_content_cell = Some(coord); } let state = self.state.as_mut().expect("checked above"); if is_chrome { // A crossing drag is normalized to the last content cell // and then deduped like any other motion — usually // suppressed, because that cell was already reported. let Some(normalized) = state.panel.gesture_last_content_cell else { return; }; if state.panel_motion_is_new(normalized) { let mods = translate_mods(self.modifiers); self.send_panel_pointer_at_cell(Some(normalized), kind, mods); } return; } if state.panel_motion_is_new(coord) { let mods = translate_mods(self.modifiers); self.send_panel_pointer_at(x, y, kind, mods); } return; } PointerSurface::Elsewhere => { if state.panel.pointer_held { // A drag that wandered out of the band keeps // belonging to the band until the button comes up. 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. let Some(state) = self.state.as_mut() else { return; }; if state.terminal.is_some() { let dragging = state.pointer_drag_active; if let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, 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(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(y as f32, state.config.height, state.fm, state.band_inset()); // Drag coalescing (predicted finding #4): pixel-rate // motion only ships when the hit byte changes. let Some(byte) = state.hit_test_source_byte(x, 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); } } /// Perform [`PointerRoute::Left`], press or release. #[allow(clippy::too_many_lines)] // one linear gesture pipeline; splitting hides the order. fn apply_left_button(&mut self, button_state: ElementState) { 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); // Bottom panel Stage 2B-3 — the divider strip and the band // claim the gesture before either document path sees it. let panel_surface = state.classify_pointer_surface(x as f32, y as f32); match button_state { ElementState::Pressed => { if panel_surface == PointerSurface::PanelDivider && state.begin_panel_drag(x as f32, y as f32) { return; } // Arm the gesture BEFORE sending, and only when the // press actually landed on a cell: arming on a miss // would make a later in-band motion send a `Drag` with // no preceding `Down`, and not arming at all means // `Drag(Left)` is never emitted and panel selection // cannot work at all. if let PointerSurface::PanelCell(coord) = panel_surface { // Parent 48 R-c: a press on the band's MODE LINE is // reserved, and must not arm. Arming would let a drag // into content emit a `Drag` with no accepted `Down` — // an orphan the daemon cannot tell from a real gesture, // and one no receiver-side rule can prevent, because the // frontend has already latched. if state.panel_cell_is_chrome(coord) { return; } let state = self.state.as_mut().expect("checked above"); state.set_panel_pointer_held(true); // R-c2: the TERMINATION FALLBACK, not the dedupe // baseline. `set_panel_pointer_held` just cleared the // latter on purpose. state.panel.gesture_last_content_cell = Some(coord); self.send_panel_pointer_at( x, y, ProtocolMouseKind::Down(ProtocolMouseButton::Left), mods, ); return; } if state.panel.pointer_held { let state = self.state.as_mut().expect("checked above"); state.set_panel_pointer_held(false); } } ElementState::Released => { if state.end_panel_drag() { return; } if state.panel.pointer_held { let cell = state.panel_release_cell(x as f32, y as f32); self.send_panel_pointer_at_cell( cell, ProtocolMouseKind::Up(ProtocolMouseButton::Left), mods, ); if let Some(state) = self.state.as_mut() { state.set_panel_pointer_held(false); } return; } } } let Some(state) = self.state.as_mut() else { return; }; 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); } } } } /// Perform [`PointerRoute::RightPress`]. /// /// 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. #[allow(clippy::too_many_lines)] // one linear gesture pipeline; splitting hides the order. fn apply_right_press(&mut self) { 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; } // Bottom panel Stage 2B-3 — a right-click in the band is a // panel gesture, claimed before the terminal and document // paths. The daemon decides between child mouse reporting and // the editor context menu, so the anchor is remembered here // exactly as for a document click; without this the band's // context actions are unreachable and the click is applied to // the document underneath instead. if let PointerSurface::PanelCell(_) = state.classify_pointer_surface(x as f32, y as f32) { if let Some(state) = self.state.as_mut() { state.menu_anchor_px = (x, y); } let mods = translate_mods(self.modifiers); self.send_panel_pointer_at( x, y, ProtocolMouseKind::Down(ProtocolMouseButton::Right), mods, ); return; } let Some(state) = self.state.as_mut() else { 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); } } /// Resolve a pointer position to the wheel target that owns it. /// /// **This runs BEFORE quantization**, which is the whole point: /// §2a CORRECTION 5 measured that rounding and the `lines == 0` /// return happened upstream of routing, so a sub-tick delta bound /// for the panel or the terminal was discarded before anything knew /// where it was going. /// /// `Chrome` is the fallthrough rather than `Document` so the /// enumeration stays total: every pixel outside the band is one of /// terminal, minimap, document or chrome, and the first three are /// each tested explicitly. fn classify_wheel_target(&mut self, x: f64, y: f64) -> WheelTarget { match self .state .as_ref() .map(|s| s.classify_pointer_surface(x as f32, y as f32)) { Some(PointerSurface::PanelCell(_)) => { if let Some((_, _, buffer, coord)) = self.panel_pointer_hit(x, y) { return WheelTarget::PanelCell { buffer, coord }; } // The band owns the pixel even when it maps to no cell. return WheelTarget::PanelChrome; } Some(PointerSurface::PanelDivider | PointerSurface::PanelBackground) => { return WheelTarget::PanelChrome; } Some(PointerSurface::Elsewhere) | None => {} } if let Some((buffer, coord)) = self.terminal_pointer_hit(x, y) { return WheelTarget::Terminal { buffer, coord }; } let Some(state) = self.state.as_mut() else { return WheelTarget::Chrome; }; if state.in_minimap_band(x, y) { return WheelTarget::Minimap; } if state.hit_test_source_byte(x, y).is_some() { return WheelTarget::Document; } WheelTarget::Chrome } /// Perform [`PointerRoute::MiddlePress`] — GUI Stage 1b B4. /// /// Reads the selection [`paste_source_for`] names for this platform /// and ships it as a `Paste`, the same wire operation Ctrl-V uses. /// The daemon inserts it; this frontend never edits the document /// itself. fn apply_middle_press(&mut self) { #[cfg(test)] let is_linux = !self.test_force_non_linux; #[cfg(not(test))] let is_linux = cfg!(target_os = "linux"); let Some(source) = paste_source_for(is_linux) else { return; }; let bytes = self .state .as_mut() .and_then(|state| state.read_os_selection(source)); if let Some(bytes) = bytes && !bytes.is_empty() && let Some(client) = self.attach_client.as_ref() && let Err(e) = client.send_paste(bytes) { eprintln!("pmacs-gpu: middle-click send_paste failed: {e}"); } } /// Perform [`PointerRoute::Wheel`]. /// /// **GUI Stage 1b B1 reorders this pipeline.** It used to round to /// whole lines and return on zero *before* consulting the pointer, /// so a sub-tick delta bound for the panel or the terminal was /// discarded by a decision taken upstream of routing. The order is /// now: classify the target, bank the fractional delta against /// **that target's** accumulator, and route only the whole ticks /// that fall out. #[allow(clippy::too_many_lines)] // one linear gesture pipeline; splitting hides the order. fn apply_wheel(&mut self, delta: MouseScrollDelta) { let Some(state) = self.state.as_ref() else { return; }; // **Fractional deltas in NOTCHES, not lines or columns.** The // notch is the unit that survives the wire: a receiver applies // its own per-notch step (`SCROLL_LINES`), so banking in lines // here would apply the step twice — one notch would move a // panel nine lines while the document moved three. The step is // applied exactly once, at the point of effect; the wire carries // notches. // // Positive winit y = scroll up = smaller scroll_top, hence the // negation. let notch_px_y = state.fm.code_line_height() * WHEEL_LINES_PER_TICK; let notch_px_x = state.mono_advance() * WHEEL_COLUMNS_PER_TICK; let (dx, dy) = match delta { winit::event::MouseScrollDelta::LineDelta(x, y) => (x, -y), winit::event::MouseScrollDelta::PixelDelta(p) => ( if notch_px_x > 0.0 { p.x as f32 / notch_px_x } else { 0.0 }, if notch_px_y > 0.0 { -(p.y as f32) / notch_px_y } else { 0.0 }, ), }; // No pointer position yet — a wheel before the first cursor // motion. The document is the target, which is what this path // did before 1b; dropping the input instead would be a // regression B1 never asked for. let (target, x, y) = match state.pointer_pos { Some((x, y)) => (self.classify_wheel_target(x, y), x, y), None => (WheelTarget::Document, 0.0, 0.0), }; // The band owns the pixel: consume both axes and bank nothing. // Panel banks are cleared so this motion can never combine with // cell input later, which is the surface-switch jump B1 forbids. if matches!(target, WheelTarget::PanelChrome) { if let Some(state) = self.state.as_mut() { state.wheel_residuals.clear_panels(); } return; } let Some(owner) = target.residual_owner() else { return; }; let Some(state) = self.state.as_mut() else { return; }; let (ticks_x, ticks_y) = state.wheel_residuals.accumulate(owner, dx, dy); if ticks_x == 0 && ticks_y == 0 { return; } let mods = translate_mods(self.modifiers); match target { WheelTarget::PanelChrome => unreachable!("consumed above"), WheelTarget::PanelCell { .. } => { for kind in wheel_kinds(ticks_x, ticks_y) { self.send_panel_pointer_at(x, y, kind, mods); } } WheelTarget::Terminal { buffer, coord } => { for kind in wheel_kinds(ticks_x, ticks_y) { self.send_terminal_pointer(buffer, coord, kind, mods); } } WheelTarget::Minimap | WheelTarget::Document | WheelTarget::Chrome => { // The local step, applied exactly once: notches become // lines here and nowhere else. if ticks_y != 0 { let lines = ticks_y * WHEEL_LINES_PER_TICK as i64; let vp = self .state .as_mut() .and_then(|state| 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}"); } } // **The minimap's horizontal axis is INERT** (§2a's // enumeration rules it so). It banks vertically like any // other target — B6 gives it its own accumulator — but a // horizontal notch over the minimap must not scroll the // document sideways. if ticks_x != 0 && !matches!(target, WheelTarget::Minimap) && let Some(state) = self.state.as_mut() { state.scroll_by_columns(ticks_x * WHEEL_COLUMNS_PER_TICK as i64); } } } } /// Perform [`LifecycleRoute::Redraw`]. A no-op before `resumed` has /// built the surface. fn apply_redraw(&mut self) { if let Some(state) = self.state.as_mut() { state.render(); } } /// Route one window event and perform it — **the whole of /// `window_event` except the exit itself**, which is returned rather /// than performed. /// /// This split is what makes P2 reachable at all. Left inside /// `window_event`, the dispatch would force a harness to /// re-implement it, and a harness that re-implements the thing it /// tests witnesses its own copy. Here the harness drives production /// code, and `window_event` keeps only the one statement no headless /// test can reach — which narrows P3 from a 33-line match to a /// single `if`. fn dispatch_window_event(&mut self, event: &WindowEvent) -> EventOutcome { match route_event(event) { Route::Lifecycle(LifecycleRoute::Exit) => return EventOutcome::Exit, Route::Lifecycle(LifecycleRoute::Modifiers(mods)) => self.modifiers = mods, Route::Lifecycle(LifecycleRoute::Resize { width, height }) => { self.apply_resize(width, height); } Route::Lifecycle(LifecycleRoute::Redraw) => self.apply_redraw(), Route::Keyboard { action: KeyAction::Press, key, } => self.apply_keyboard(&key.logical_key, key.text.as_deref()), Route::Pointer(PointerRoute::Moved { x, y }) => self.apply_cursor_moved(x, y), Route::Pointer(PointerRoute::Left(button_state)) => { self.apply_left_button(button_state); } Route::Pointer(PointerRoute::RightPress) => self.apply_right_press(), Route::Pointer(PointerRoute::Wheel(delta)) => self.apply_wheel(delta), // Three different facts, merged only because all three are // today nothing to do: a key-up the keyboard family claimed // and dropped, a button the pointer family has no semantics // for, and an event no family claims at all. Route::Pointer(PointerRoute::MiddlePress) => self.apply_middle_press(), Route::Keyboard { action: KeyAction::Release, .. } | Route::Pointer(PointerRoute::UnusedButton) | Route::Unrouted => {} } EventOutcome::Continue } /// Perform [`KeyAction::Press`]. The router has already discarded /// key-ups, so this is always a press. /// /// **Takes the two fields it reads rather than the whole /// `KeyEvent`, deliberately.** `KeyEvent` carries a `pub(crate)` /// field and cannot be constructed outside winit, so a body taking /// one is undrivable by any test; `Key` and `&str` are ordinary /// values. That distinction is not academic — 1a's first review /// found the `TextInput` selection sited *below* the intercept /// return, making A7 and A8 reachable only when no prompt and no /// terminal were present. The classifier was correct throughout; /// only the call site was wrong, so only a test that drives THIS /// function could have caught it. /// /// The router arm remains unwitnessable — it still cannot be handed /// a `WindowEvent::KeyboardInput` — so 1-pre's structural exception /// narrows to that one pattern arm rather than disappearing. #[allow(clippy::too_many_lines)] // one linear key pipeline; splitting hides the order. fn apply_keyboard(&mut self, logical: &Key, text: Option<&str>) { // 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); // A4 / Q#S1-1 — **every** Escape reaches the daemon, and none // exits. // // It used to quit the frontend when nothing was intercepting, // which made the GUI's most common "get me out of this" key // destroy the window instead of cancelling. Q#S1-1 settles the // exits and Escape is not among them: a native close detaches // this frontend, `editor.quit` shuts the daemon and its // attachments down, and **Escape only cancels or round-trips**. // // The `intercept || completion_open` test went with the quit // branch. It never decided what to SEND — both arms sent the // same `Escape` — only whether to send at all, so with one // behaviour left there is nothing for it to choose. (Both flags // remain live below, for the OS-paste, round-trip and // completion-accept paths.) if matches!(*logical, Key::Named(NamedKey::Escape)) { if let Some(client) = self.attach_client.as_ref() && let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE) { eprintln!("pmacs-gpu: send Escape failed: {e}"); } return; } let Some((pkey, mut pmods)) = translate_key(logical, 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(text, 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; }; // A5 — multi-scalar text travels as ONE `TextInput`, if the // session can carry it. // // **This MUST precede the intercept branch below, and that // placement is the contract rather than a preference.** A // modal prompt or a focused terminal is exactly what makes // `daemon_intercepts_keys` true, so classifying after it would // leave A7 (prompts consume scalars in order) and A8 (terminals // take raw UTF-8) reachable only when neither a prompt nor a // terminal is present — which is to say, never. Sited here, the // producer sends the same `TextInput` in every state and the // daemon's `dispatch_text_input` applies §5's modal precedence, // which is where that decision belongs: the frontend cannot see // which shadow is up. // // Ordering against the branches below is safe by construction, // not by luck: `text_input_payload` returns `None` whenever a // command modifier is held, so the Ctrl-V paste and // command-chord paths can never be shadowed by it. // // **The version gate WITHHOLDS rather than degrades.** A `< 24` // daemon keeps exactly the behaviour it has — including today's // truncation to the first scalar — because the fallback is the // unchanged `Key` path below. No regression, not retroactive // correctness. if let Some(text) = text_input_payload(logical, text, pmods) && client.session_protocol_version() >= TEXT_INPUT_MIN_VERSION { if let Some(state) = self.state.as_mut() { state.mark_cursor_stale_after_round_trip(); } if debug_input() { eprintln!( "pmacs-gpu send_text_input: {} scalars", text.chars().count() ); } let client = self.attach_client.as_ref().expect("client checked above"); if let Err(e) = client.send_text_input(text) { eprintln!("pmacs-gpu: send_text_input failed: {e}"); } 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}"); } } } /// GUI Stage 1-pre — the input seam. /// /// `window_event` receives a `WindowEvent` and, until this seam existed, /// decided *and performed* everything in one 655-line match. Nothing /// below it could be witnessed without a display: `ActiveEventLoop` is /// non-constructible outside a live event loop, and the arms that matter /// reach a GPU surface or a socket. /// /// The seam splits the two halves. **Deciding** is [`route_event`] — a /// free function over `&WindowEvent` alone, so a headless test can drive /// it for **every family whose event winit lets a test construct**, /// which is all of them except keyboard; see [`route_keyboard`] for that /// exception and how far it reaches. **Performing** stays on `App`, in /// the `apply_*` methods the router's variants name. /// /// A route carries the decision, not merely the family: `Resize` holds /// the clamped extent, `Modifiers` the new state. **What a route does /// NOT carry is the effect** — a `Wheel` may become a viewport update, a /// panel event, a terminal event or nothing at all, depending on /// `State`. Effects are witnessed separately, by `EffectHarness`. #[derive(Debug, Clone, Copy, PartialEq)] enum Route<'a> { /// The lifecycle family — see [`route_lifecycle`]. Lifecycle(LifecycleRoute), /// The keyboard family — see [`route_keyboard`]. Keyboard { action: KeyAction, key: &'a KeyEvent, }, /// The pointer family — see [`route_pointer`]. Pointer(PointerRoute), /// No family claims this event: pmacs ignores it. Unrouted, } /// What the event loop must do once a family's body has run. /// /// **Since A4, `LifecycleRoute::Exit` — a native window close — is the /// SOLE producer.** `apply_keyboard` used to be the second, for the /// idle-Escape local quit; A4 deleted that branch and with it the /// keyboard body's need to return anything, so it returns `()` and the /// obsolete channel is gone rather than merely unused. /// /// **One producer is not one variant.** The type stays because /// `dispatch_window_event` must still distinguish `Continue` from /// `Exit` on every event it handles: nearly all must not exit, and the /// close must. Returning the decision rather than taking an /// `&ActiveEventLoop` is also what keeps the bodies reachable from a /// test — the crate has exactly one executable `event_loop.exit()`, in /// `window_event`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EventOutcome { Continue, Exit, } /// The lifecycle family: events about the **window itself** — closing, /// resizing, repainting — rather than about a gesture aimed into the /// document. `ModifiersChanged` is grouped here as the one exception, /// and it is named as one: it is a bare state mutation with no gesture /// of its own and no body to extract. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LifecycleRoute { /// `CloseRequested` — leave the event loop. Q#S1-1: a native close /// detaches this frontend, it does not shut the daemon down. Exit, /// `ModifiersChanged` — the new modifier state, already unwrapped /// from winit's `Modifiers` wrapper. Modifiers(winit::keyboard::ModifiersState), /// `Resized` — the new surface extent, **already clamped away from /// zero**. A minimize delivers `0×0`, and wgpu rejects a zero-extent /// surface configuration, so the clamp is a rule rather than /// defensive padding; deciding it here is what makes it witnessable /// without a surface. Resize { width: u32, height: u32 }, /// `RedrawRequested` — paint a frame. Nothing goes to the daemon, /// which is why the harness records local effects rather than /// outbound traffic. Redraw, } /// The keyboard family's whole decision. `Release` is a route rather /// than an absence: the family **claims** a key-up and drops it, which /// is a different fact from no family claiming the event, and /// conflating the two would hide the drop the moment a later slice /// wants key-up semantics. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum KeyAction { /// A key-down — the only keyboard state pmacs acts on. Press, /// A key-up. Claimed and deliberately discarded. Release, } /// Decide what `window_event` should do with an event, from the event /// alone. See [`Route`]. fn route_event(event: &WindowEvent) -> Route<'_> { if let Some(lifecycle) = route_lifecycle(event) { return Route::Lifecycle(lifecycle); } if let Some((action, key)) = route_keyboard(event) { return Route::Keyboard { action, key }; } if let Some(pointer) = route_pointer(event) { return Route::Pointer(pointer); } Route::Unrouted } /// The lifecycle family's decision. `None` means some other family owns /// the event. fn route_lifecycle(event: &WindowEvent) -> Option { match event { WindowEvent::CloseRequested => Some(LifecycleRoute::Exit), WindowEvent::ModifiersChanged(mods) => Some(LifecycleRoute::Modifiers(mods.state())), WindowEvent::Resized(size) => Some(LifecycleRoute::Resize { width: size.width.max(1), height: size.height.max(1), }), WindowEvent::RedrawRequested => Some(LifecycleRoute::Redraw), _ => None, } } /// The keyboard family's claim. `None` means some other family owns the /// event. /// /// **A SECOND ACCEPTED STRUCTURAL EXCEPTION, alongside P3.** This /// function's own pattern arm is unwitnessable: `KeyEvent` carries a /// `pub(crate) platform_specific` field, so **no `WindowEvent::KeyboardInput` /// can be constructed outside winit** and no headless test can feed one. /// The limitation is winit's and not this seam's — it is why the arm is /// kept to a pattern and a call, with the family's only real decision /// factored into [`route_key_action`], which takes an `ElementState` and /// is tested directly. The pointer families have no such problem: /// `DeviceId::dummy()` is provided by winit for exactly this purpose, /// and their events are constructible. fn route_keyboard(event: &WindowEvent) -> Option<(KeyAction, &KeyEvent)> { match event { WindowEvent::KeyboardInput { event: key, .. } => Some((route_key_action(key.state), key)), _ => None, } } /// Press or release — see [`KeyAction`]. fn route_key_action(state: ElementState) -> KeyAction { match state { ElementState::Pressed => KeyAction::Press, ElementState::Released => KeyAction::Release, } } /// The pointer family — Session M-2 pointer input, see /// `docs/pmacs-gpu-mouse-framing.md`. /// /// The button discrimination used to live in the shape of two /// overlapping `MouseInput` match arms — left in **either** state, right /// in the **pressed** state only, everything else falling through the /// wildcard several hundred lines away. Naming the cases makes the /// asymmetry a decision instead of an artefact of arm order. #[derive(Debug, Clone, Copy, PartialEq)] enum PointerRoute { /// `CursorMoved`, at the physical position winit reported. Moved { x: f64, y: f64 }, /// `MouseInput` on the left button, in **either** state: a press /// starts a selection or a scrub, a release ends it. Left(ElementState), /// `MouseInput` **pressing** the right button. The context menu /// opens on the press, so the matching release is deliberately /// nothing — see `UnusedButton`. RightPress, /// `MouseInput` **pressing** the middle button — GUI Stage 1b's /// B4. On Linux this pastes the **PRIMARY selection**, which is the /// platform convention and a different selection from the one /// Ctrl-V reads. The release is deliberately nothing, like the /// right button's. MiddlePress, /// A `MouseInput` this frontend has no semantics for: the right and /// middle buttons' releases, and every back / forward / other button /// in either state. **Claimed by the pointer family and dropped**, /// exactly as it behaved when it fell through to the wildcard. UnusedButton, /// `MouseWheel`. The delta is carried raw: converting it to lines /// needs the code line height, which is `State`'s to know. Wheel(MouseScrollDelta), } /// The pointer family's decision. `None` means some other family owns /// the event. fn route_pointer(event: &WindowEvent) -> Option { match event { WindowEvent::CursorMoved { position, .. } => Some(PointerRoute::Moved { x: position.x, y: position.y, }), WindowEvent::MouseInput { state, button, .. } => Some(match (button, state) { (MouseButton::Left, _) => PointerRoute::Left(*state), (MouseButton::Right, ElementState::Pressed) => PointerRoute::RightPress, (MouseButton::Middle, ElementState::Pressed) => PointerRoute::MiddlePress, _ => PointerRoute::UnusedButton, }), WindowEvent::MouseWheel { delta, .. } => Some(PointerRoute::Wheel(*delta)), _ => None, } } /// GUI Stage 1-pre — the headless routing harness (P2). /// /// It feeds `WindowEvent`s through the production [`route_event`] and /// records what each one routed to. The transcript is of **routes**, /// deliberately, not of outbound protocol traffic: `CloseRequested` /// exits and `RedrawRequested` repaints, and neither sends the daemon /// anything, so a transcript of daemon traffic alone cannot tell a /// handled arm from a dropped one. A route records the decision — /// exit, resize extent, modifier mutation — which is what makes those /// arms observable here at all. /// /// P3 is the stated exception: that `window_event` *calls* the router /// rather than deciding for itself is a code-review invariant, not a /// tested one. `ActiveEventLoop` cannot be constructed outside a live /// event loop, so the real callback is unreachable from a test. The /// mutation evidence below covers every router arm and **not** the /// delegation. #[cfg(test)] #[derive(Debug, Default)] struct RoutingHarness<'a> { transcript: Vec>, } #[cfg(test)] impl<'a> RoutingHarness<'a> { fn feed(&mut self, event: &'a WindowEvent) -> Route<'a> { let route = route_event(event); self.transcript.push(route); route } fn transcript(&self) -> &[Route<'a>] { &self.transcript } } /// GUI Stage 1-pre — the headless EFFECT harness (P2). /// /// [`RoutingHarness`] above answers "where did this event go?". /// This one answers **"what did it do?"** — the other half of P2, and /// the half a route cannot supply on its own. A wheel route carries a /// delta; whether that delta becomes a panel event, a terminal event, a /// viewport update, or nothing at all depends on `State`, so only /// running the body can say. /// /// It drives production code end to end: /// /// * **A real `AttachClient` over a `socketpair`**, through the real /// handshake, outbox, writer thread and encoder. The harness reads /// encoded `pmacs_protocol::FrontendEvent`s off the daemon end, so what it records is /// the wire, not a mock's idea of it. /// * **A real windowless `State`**, so the bodies take their real /// branches. /// * **`App::dispatch_window_event`**, the production dispatch — not a /// re-implementation of it, which would witness only itself. /// /// **Local effects** have no wire trace, so each is read from the place /// it actually lands: exit from the returned [`EventOutcome`], redraw /// from `State::render_calls`, resize from the surface config, and the /// modifier mutation from `App::modifiers`. /// /// **Two honest limits, both structural.** /// /// 1. `window_event` itself is still unreachable (P3) — the harness /// calls `dispatch_window_event`, which is everything `window_event` /// does except the `event_loop.exit()` it cannot construct. /// 2. A step is delimited by a **sentinel key** pushed through the same /// outbox, so "this step sent nothing" is decidable without a sleep. /// The sentinel is not coalesceable (only viewport and drag kinds /// are), so it can neither replace nor be replaced by a recorded /// event — but it does sit between steps, so consecutive same-kind /// events that production would coalesce are recorded separately /// here. That makes the transcript per-step rather than as-coalesced, /// which is what a per-step contract wants. #[cfg(test)] struct EffectHarness { app: App, daemon: std::os::unix::net::UnixStream, /// Distinguishes one sentinel from the next, so a step cannot end on /// a stale one left behind by an earlier read. sentinel_seq: u32, } /// A local effect — something a dispatch did that leaves no wire trace. #[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum LocalEffect { /// The dispatch asked the event loop to exit. Exit, /// `State::render` was called. Redraw, /// The surface was reconfigured to this extent. Resize { width: u32, height: u32 }, /// The document scrolled to this top line. Scroll { top: usize }, /// `App::modifiers` changed to this value. Modifiers(winit::keyboard::ModifiersState), } /// What one dispatched event did. #[cfg(test)] #[derive(Debug, Clone, PartialEq)] struct Step { local: Vec, outbound: Vec, } #[cfg(test)] impl EffectHarness { /// The sentinel key. A private-use scalar, so it cannot collide with /// anything a body would legitimately send. const SENTINEL: char = '\u{e000}'; /// Error ceiling on one outbound read. **Not a pacing device** — the /// sentinel decides arrival — so this is set far above any plausible /// drain: reaching it means the writer or encoder is broken, never /// that the machine is busy. Without it a regression downstream of /// `enqueue` wedges the gate instead of reddening it. const READ_CEILING: std::time::Duration = std::time::Duration::from_secs(30); /// Build the harness, or fail loudly. /// /// **Never skips.** `State::new_headless` returns `None` when no /// wgpu adapter exists, and a suite that quietly returns `ok` in /// that case proves nothing — this project has recorded that failure /// mode twice. Under `PMACS_REQUIRE_GPU` the absence is an assertion /// failure; without it the harness still panics rather than skips, /// because these rows are the whole of P2. fn new() -> Self { // A document tall enough to scroll. A two-line fixture made the // wheel row pass vacuously: `scroll_by_lines` returns `None` // when there is nothing below the fold, so the row asserted // "every outbound event is a Viewport" over an EMPTY transcript. // Mutation M22 surfaced the first half of that and this fixture // is the second. Self::with_document(&"line\n".repeat(200)) } /// The same harness over a caller-chosen document, for rows whose /// claim depends on the text's shape — B6's horizontal contrast /// needs lines wider than the viewport, which the default fixture's /// four columns can never provide. fn with_document(document: &str) -> Self { let (client_stream, mut daemon) = std::os::unix::net::UnixStream::pair().expect("socketpair"); // The daemon half of the handshake, synchronous and inline: the // client blocks reading `Hello` before it writes anything, so a // thread would only add a join. let handshake = std::thread::spawn(move || { pmacs_protocol::write_message(&mut daemon, &test_hello()).expect("write Hello"); let _: pmacs_protocol::AttachRequest = pmacs_protocol::read_message(&mut daemon).expect("read AttachRequest"); let _: pmacs_protocol::SessionBootstrapRequest = pmacs_protocol::read_message(&mut daemon).expect("read bootstrap"); daemon }); let client = crate::attach::connect_stream_for_test(client_stream, |_| true) .expect("attach over socketpair"); let daemon = handshake.join().expect("handshake thread"); daemon .set_read_timeout(Some(Self::READ_CEILING)) .expect("arm the outbound read ceiling"); let state = State::new_headless(640, 480, document); assert!( state.is_some(), "no wgpu adapter: the 1-pre effect rows are P2's only witness and must not be skipped" ); let mut harness = Self { app: App { mode: Mode::Attach { socket: PathBuf::from("/nonexistent-harness-socket"), }, proxy: None, #[cfg(test)] test_force_non_linux: false, state, pending_events: Vec::new(), attach_client: Some(client), modifiers: winit::keyboard::ModifiersState::empty(), }, daemon, sentinel_seq: 0, }; // Mirror the post-connect wiring `resumed` performs. Without it // the panel wire stays below `PANEL_MIN_VERSION` and every // geometry declaration is silently withheld — the harness would // then witness an absence it created itself. Getting this wrong // is exactly what the first run of the resize row caught. { let client = harness.app.attach_client.as_ref().expect("harness client"); let frontend_id = client.frontend_id(); let session_version = client.session_protocol_version(); let state = harness.app.state.as_mut().expect("harness state"); state.set_frontend_id(frontend_id); state.set_panel_wire(session_version); // Stands in for the `BufferSnapshot` the daemon would send. // Without a current buffer `scroll_by_lines` scrolls locally // and returns `None`, so every viewport send is withheld and // the harness would witness an absence it manufactured — // the same trap the panel wire set above. This is the // pattern the file's other headless-State tests already use. state.current_buffer_id = Some(BufferId::from_raw(1)); } harness.app.flush_panel_geometry(GeometryTrigger::Surface); // Drain the attach-time declaration, so each row's transcript // contains only what its own event produced. harness.read_until_sentinel(); harness } /// Drive `apply_keyboard` directly and report what it produced. /// /// Bypasses `route_event` because a `WindowEvent::KeyboardInput` /// cannot be constructed outside winit — 1-pre's recorded /// structural exception. What that exception covers is the router's /// pattern arm; the BODY is reachable, and the body is where 1a's /// placement defect lived. fn feed_keyboard(&mut self, logical: &Key, text: Option<&str>) -> Step { let before = self.snapshot(); self.app.apply_keyboard(logical, text); let after = self.snapshot(); Step { local: Self::diff(&before, &after, EventOutcome::Continue), outbound: self.read_until_sentinel(), } } /// Dispatch one event and report everything it did. fn feed(&mut self, event: &WindowEvent) -> Step { let before = self.snapshot(); let outcome = self.app.dispatch_window_event(event); let after = self.snapshot(); Step { local: Self::diff(&before, &after, outcome), outbound: self.read_until_sentinel(), } } /// The local effects between two snapshots. Shared by every entry /// point so a new one cannot observe a different set by accident. fn diff( before: &EffectSnapshot, after: &EffectSnapshot, outcome: EventOutcome, ) -> Vec { let mut local = Vec::new(); if outcome == EventOutcome::Exit { local.push(LocalEffect::Exit); } if after.render_calls > before.render_calls { local.push(LocalEffect::Redraw); } if after.extent != before.extent { local.push(LocalEffect::Resize { width: after.extent.0, height: after.extent.1, }); } if after.modifiers != before.modifiers { local.push(LocalEffect::Modifiers(after.modifiers)); } if after.scroll_top != before.scroll_top { local.push(LocalEffect::Scroll { top: after.scroll_top, }); } local } /// Observable state the local effects are derived from. fn snapshot(&self) -> EffectSnapshot { let state = self.app.state.as_ref().expect("harness state"); EffectSnapshot { render_calls: state.render_calls, extent: (state.config.width, state.config.height), modifiers: self.app.modifiers, scroll_top: state.scroll_top, } } /// Push a sentinel through the same outbox and read until it comes /// back. Everything ahead of it belongs to the step just dispatched. /// /// **The sentinel is the success condition, and the timeout is only /// an error ceiling** — the two are not the same thing and the /// distinction is the whole design. Arrival is decided by the /// sentinel, so the harness never infers "nothing was sent" from a /// duration and is insensitive to how many cores are free; that is /// the mistake PR #235's CI red was made of. But a blocking read /// with no bound turns a regressed writer or encoder into a **wedged /// gate** rather than a red one, and a hang is the worst failure /// shape there is: it looks like slowness until the job is killed. /// [`Self::READ_CEILING`] is therefore set far above any plausible /// drain, so reaching it means broken, never busy. fn read_until_sentinel(&mut self) -> Vec { self.sentinel_seq += 1; let tag = self.sentinel_seq; let client = self.app.attach_client.as_ref().expect("harness client"); client .send_key(ProtocolKey::Char(Self::SENTINEL), sentinel_mods(tag)) .expect("enqueue sentinel"); let mut seen = Vec::new(); loop { let event: pmacs_protocol::FrontendEvent = match pmacs_protocol::read_message(&mut self.daemon) { Ok(event) => event, Err(e) => panic!( "outbound read failed before the sentinel arrived \ after {:?}: {e}. Either the writer or the encoder \ regressed, or the step produced no sentinel at all. \ Recorded so far: {seen:?}", Self::READ_CEILING ), }; if is_sentinel(&event, tag) { return seen; } seen.push(event); } } } /// Observable state behind [`LocalEffect`]. #[cfg(test)] struct EffectSnapshot { render_calls: u64, extent: (u32, u32), modifiers: winit::keyboard::ModifiersState, scroll_top: usize, } /// Modifier bits carrying the sentinel's sequence number, so a stale /// sentinel cannot end the wrong step. **Three bits — Ctrl, Alt, Shift — /// so the tag wraps every eight steps.** That is sufficient rather than /// sloppy: the harness reads every sentinel it writes before issuing the /// next, so a tag only ever has to distinguish itself from the one /// immediately before it. #[cfg(test)] fn sentinel_mods(tag: u32) -> Modifiers { let mut mods = Modifiers::NONE; if tag & 1 != 0 { mods |= Modifiers::CTRL; } if tag & 2 != 0 { mods |= Modifiers::ALT; } if tag & 4 != 0 { mods |= Modifiers::SHIFT; } mods } #[cfg(test)] fn is_sentinel(event: &pmacs_protocol::FrontendEvent, tag: u32) -> bool { matches!( event, pmacs_protocol::FrontendEvent::Key(k) if k.key == ProtocolKey::Char(EffectHarness::SENTINEL) && k.mods == sentinel_mods(tag) ) } #[cfg(test)] fn test_hello() -> pmacs_protocol::Hello { pmacs_protocol::Hello { protocol_version: pmacs_protocol::PROTOCOL_VERSION, assigned_frontend_id: pmacs_protocol::FrontendId(7), instance_identity: pmacs_protocol::InstanceIdentity { pmacs_version: "1-pre-harness".to_owned(), build_hash: None, instance_name: None, uptime_secs: 0, working_directory: "/".to_owned(), }, instance_capabilities: pmacs_protocol::InstanceCapabilities { multi_frontend: true, crdt_replica: true, semantic_render: true, }, } } #[cfg(test)] mod input_routing_tests { use super::*; use winit::dpi::{PhysicalPosition, PhysicalSize}; use winit::event::{DeviceId, TouchPhase}; use winit::keyboard::ModifiersState; fn modifiers_changed(state: ModifiersState) -> WindowEvent { WindowEvent::ModifiersChanged(state.into()) } /// The per-variant rows below drive [`route_event`] directly and the /// transcript row drives [`RoutingHarness`]. That split keeps the /// transcript row the only one that fails when the ROUTING harness /// stops recording, so that mutation stays surgical. **P2 itself is /// owned by the effect rows** — see [`EffectHarness`]. /// /// Events are bound to locals rather than passed as temporaries /// because a `Route` borrows the event it came from — the keyboard /// variant carries a `&KeyEvent`. fn route_one(event: &WindowEvent) -> Route<'_> { route_event(event) } /// P1 — `CloseRequested`. It sends the daemon nothing and its whole /// effect is local, so this row exists only because the route names /// the effect. #[test] fn close_requested_routes_to_exit() { let event = WindowEvent::CloseRequested; assert_eq!(route_one(&event), Route::Lifecycle(LifecycleRoute::Exit)); } /// P1 — `ModifiersChanged` carries the unwrapped state, which is the /// mutation `window_event` performs. #[test] fn modifiers_changed_routes_the_new_state() { let mods = ModifiersState::CONTROL | ModifiersState::ALT; let held = modifiers_changed(mods); assert_eq!( route_one(&held), Route::Lifecycle(LifecycleRoute::Modifiers(mods)) ); // An empty state is a real transition (every modifier released), // not an absent one. let released = modifiers_changed(ModifiersState::empty()); assert_eq!( route_one(&released), Route::Lifecycle(LifecycleRoute::Modifiers(ModifiersState::empty())) ); } /// P1 — `Resized` carries the extent through unchanged when it is /// already non-zero. #[test] fn resized_routes_the_new_extent() { let event = WindowEvent::Resized(PhysicalSize::new(1280, 720)); assert_eq!( route_one(&event), Route::Lifecycle(LifecycleRoute::Resize { width: 1280, height: 720, }) ); } /// A minimize delivers `0×0` and wgpu rejects a zero-extent surface /// configuration. The clamp is per axis, so an extent that collapses /// on one axis only still keeps the other. #[test] fn a_zero_extent_resize_clamps_per_axis() { let collapsed = WindowEvent::Resized(PhysicalSize::new(0, 0)); assert_eq!( route_one(&collapsed), Route::Lifecycle(LifecycleRoute::Resize { width: 1, height: 1, }) ); let no_width = WindowEvent::Resized(PhysicalSize::new(0, 720)); assert_eq!( route_one(&no_width), Route::Lifecycle(LifecycleRoute::Resize { width: 1, height: 720, }) ); let no_height = WindowEvent::Resized(PhysicalSize::new(1280, 0)); assert_eq!( route_one(&no_height), Route::Lifecycle(LifecycleRoute::Resize { width: 1280, height: 1, }) ); } /// P1 — `RedrawRequested`. The second arm that sends the daemon /// nothing, and the reason the harness cannot be a transcript of /// outbound traffic. #[test] fn redraw_requested_routes_to_redraw() { let event = WindowEvent::RedrawRequested; assert_eq!(route_one(&event), Route::Lifecycle(LifecycleRoute::Redraw)); } /// P1, keyboard — the family's whole decision. /// /// It is driven through [`route_key_action`] rather than the /// harness, and that is the accepted exception documented on /// [`route_keyboard`]: `KeyEvent` has a `pub(crate)` field, so no /// `WindowEvent::KeyboardInput` exists that a test can build. What /// remains unwitnessed is one pattern arm with no logic in it; the /// decision itself is here. #[test] fn a_press_is_acted_on_and_a_release_is_discarded() { assert_eq!(route_key_action(ElementState::Pressed), KeyAction::Press); assert_eq!(route_key_action(ElementState::Released), KeyAction::Release); } fn mouse_input(state: ElementState, button: MouseButton) -> WindowEvent { WindowEvent::MouseInput { device_id: DeviceId::dummy(), state, button, } } /// P1 — `CursorMoved` carries the position through. #[test] fn cursor_moved_routes_the_position() { let event = WindowEvent::CursorMoved { device_id: DeviceId::dummy(), position: PhysicalPosition::new(12.5, 34.25), }; assert_eq!( route_one(&event), Route::Pointer(PointerRoute::Moved { x: 12.5, y: 34.25 }) ); } /// P1 — the left button routes in **both** states, because a press /// starts a gesture and a release ends it. The state is carried, not /// discarded: routing a release as a press would leave a drag armed /// forever. #[test] fn the_left_button_routes_in_either_state() { for state in [ElementState::Pressed, ElementState::Released] { let event = mouse_input(state, MouseButton::Left); assert_eq!( route_one(&event), Route::Pointer(PointerRoute::Left(state)), "left {state:?}" ); } } /// P1 — the right button is **asymmetric with the left, on purpose**: /// the context menu opens on the press and the release means nothing. /// This asymmetry used to be implicit in the order and shape of two /// overlapping match arms. #[test] fn the_right_button_routes_only_on_press() { let pressed = mouse_input(ElementState::Pressed, MouseButton::Right); assert_eq!( route_one(&pressed), Route::Pointer(PointerRoute::RightPress) ); let released = mouse_input(ElementState::Released, MouseButton::Right); assert_eq!( route_one(&released), Route::Pointer(PointerRoute::UnusedButton) ); } /// A button the frontend has no semantics for is **claimed by the /// pointer family and dropped**, not left unrouted — the same /// distinction the keyboard family draws for a key-up. Stage 1b's B4 /// gives the middle button a meaning and lands on this route. #[test] fn a_button_without_semantics_is_claimed_and_dropped() { for button in [ MouseButton::Back, MouseButton::Forward, MouseButton::Other(9), ] { for state in [ElementState::Pressed, ElementState::Released] { let event = mouse_input(state, button); assert_eq!( route_one(&event), Route::Pointer(PointerRoute::UnusedButton), "{button:?} {state:?}" ); } } // **The middle button is no longer semantics-free**: GUI Stage // 1b's B4 gives its PRESS a meaning. Its RELEASE still has none, // so it stays in this row rather than leaving it. assert_eq!( route_one(&mouse_input(ElementState::Released, MouseButton::Middle)), Route::Pointer(PointerRoute::UnusedButton), "a middle release remains nothing, like the right button's" ); } /// P1 — the wheel delta is carried **raw**. Converting it to lines /// needs the code line height, which only `State` knows, so the /// router must not try. #[test] fn the_wheel_carries_its_delta_unconverted() { let lines = WindowEvent::MouseWheel { device_id: DeviceId::dummy(), delta: MouseScrollDelta::LineDelta(0.0, -3.0), phase: TouchPhase::Moved, }; assert_eq!( route_one(&lines), Route::Pointer(PointerRoute::Wheel(MouseScrollDelta::LineDelta(0.0, -3.0))) ); let pixels = WindowEvent::MouseWheel { device_id: DeviceId::dummy(), delta: MouseScrollDelta::PixelDelta(PhysicalPosition::new(0.0, 17.5)), phase: TouchPhase::Moved, }; assert_eq!( route_one(&pixels), Route::Pointer(PointerRoute::Wheel(MouseScrollDelta::PixelDelta( PhysicalPosition::new(0.0, 17.5) ))) ); } /// An event no family claims. `Occluded` is chosen because pmacs has /// never handled it and — unlike a mouse button it has no semantics /// for — no family claims it at all. #[test] fn an_unclaimed_event_is_unrouted() { let event = WindowEvent::Occluded(true); assert_eq!(route_one(&event), Route::Unrouted); } // --------------------------------------------------------------- // P2 — the EFFECT rows. `EffectHarness` drives the production // dispatch over a real client and a real windowless `State`, and // records BOTH halves of P2: the outbound protocol messages and the // local effects that leave no wire trace. // --------------------------------------------------------------- /// P2 — a redraw's whole effect is local. It sends the daemon /// nothing, so **the outbound half of the transcript is empty and /// the local half is what proves the arm ran** — which is precisely /// why P2 requires both. #[test] fn a_redraw_produces_a_local_effect_and_no_outbound_traffic() { let mut h = EffectHarness::new(); let step = h.feed(&WindowEvent::RedrawRequested); assert_eq!(step.local, vec![LocalEffect::Redraw]); assert!( step.outbound.is_empty(), "a redraw must send the daemon nothing, got {:?}", step.outbound ); } /// P2 — `CloseRequested` is the other silent arm: one local effect, /// no traffic. #[test] fn a_close_request_exits_locally_and_sends_nothing() { let mut h = EffectHarness::new(); let step = h.feed(&WindowEvent::CloseRequested); assert_eq!(step.local, vec![LocalEffect::Exit]); assert!(step.outbound.is_empty(), "{:?}", step.outbound); } /// P2 — a modifier change mutates `App` and sends nothing. The /// mutation is the effect. #[test] fn a_modifier_change_mutates_state_and_sends_nothing() { let mut h = EffectHarness::new(); let mods = ModifiersState::CONTROL; let step = h.feed(&modifiers_changed(mods)); assert_eq!(step.local, vec![LocalEffect::Modifiers(mods)]); assert!(step.outbound.is_empty(), "{:?}", step.outbound); assert_eq!(h.app.modifiers, mods); } /// P2 — a resize is the one lifecycle arm with **both** halves: it /// reconfigures the surface locally and declares the new cell /// geometry to the daemon. #[test] fn a_resize_reconfigures_locally_and_declares_geometry() { let mut h = EffectHarness::new(); let step = h.feed(&WindowEvent::Resized(PhysicalSize::new(900, 500))); assert_eq!( step.local, vec![LocalEffect::Resize { width: 900, height: 500 }] ); assert!( step.outbound.iter().any(|e| matches!( e, pmacs_protocol::FrontendEvent::FrontendCellGeometry { .. } )), "a resize must declare the new cell geometry, got {:?}", step.outbound ); } /// P2 — the zero-extent clamp, observed as a real surface /// configuration rather than as a route value. wgpu rejects a /// zero-extent surface, so this is the row that would fail if the /// clamp were only cosmetic. #[test] fn a_minimize_configures_a_nonzero_surface() { let mut h = EffectHarness::new(); let step = h.feed(&WindowEvent::Resized(PhysicalSize::new(0, 0))); assert_eq!( step.local, vec![LocalEffect::Resize { width: 1, height: 1 }] ); } /// Parent 48 R-c, the PRODUCER half — a press on the band's mode line /// must neither send nor arm, and the row above it must do both. /// /// This drives the real `MouseInput` path and reads the wire, because /// the hazard is precisely that the frontend latches BEFORE the daemon /// can refuse: `panel_hit_test` reports across the whole frame, so a /// chrome press is indistinguishable from a content press to the /// arming code, and once armed, a drag into content emits a `Drag` /// with no accepted `Down`. No receiver-side rule can undo that. /// /// The content leg is not decoration: without it, an implementation /// that never arms anywhere passes the chrome half. #[test] fn a_press_on_the_bands_mode_line_neither_sends_nor_arms() { let mut h = EffectHarness::new(); let rows = 4; { // Inlined rather than shared: `present_panel` lives in the // other test module. Same shape — wire on, one declaration, // one `Present`. let state = h.app.state.as_mut().expect("harness state"); state.set_panel_wire(PANEL_MIN_VERSION); // The harness already declared during setup, so the `Surface` // trigger dedups; reuse the standing declaration rather than // asserting a second one. let geometry_epoch = state .next_geometry_declaration(GeometryTrigger::Surface) .map_or(state.panel.geometry_epoch, |(epoch, _)| epoch); assert_ne!( geometry_epoch, 0, "a declaration must exist to present against" ); let cols = state.declared_cell_total().0.cols.max(1); let frame = pmacs_protocol::panel::PanelFrame { buffer_id: BufferId::from_raw(77), panel_epoch: 1, geometry_epoch, size: CellSize::new(rows, cols), cells: vec![pmacs_protocol::Cell::default(); (rows * cols) as usize], cursor: None, focused: true, }; assert!( state.apply_panel_payload(pmacs_protocol::panel::PanelFramePayload::Present(frame)), "installing a first frame changes the band" ); } let (ox, oy, _, band_h) = h .app .state .as_ref() .expect("harness state") .panel_content_rect() .expect("a presented band has a rect"); let row_h = band_h / rows as f32; let press_at = |h: &mut EffectHarness, y: f32| { h.feed(&WindowEvent::CursorMoved { device_id: DeviceId::dummy(), position: PhysicalPosition::new(f64::from(ox) + 0.5, f64::from(y)), }); h.feed(&WindowEvent::MouseInput { device_id: DeviceId::dummy(), state: ElementState::Pressed, button: MouseButton::Left, }) }; let panel_events = |step: &Step| { step.outbound .iter() .filter(|event| matches!(event, pmacs_protocol::FrontendEvent::PanelPointer { .. })) .count() }; // Chrome: the band's LAST row. let step = press_at(&mut h, oy + band_h - 0.5); assert_eq!( panel_events(&step), 0, "a press on the mode line is reserved and reaches no daemon" ); assert!( !h.app .state .as_ref() .expect("harness state") .panel .pointer_held, "and it must not ARM — an armed chrome press turns the next \ motion into a `Drag` with no accepted `Down`" ); // Content: one row up, same column, same gesture. let step = press_at(&mut h, oy + band_h - row_h - 0.5); assert_eq!( panel_events(&step), 1, "the row above chrome is content and must still work — without \ this leg, never arming anywhere passes the half above" ); assert!( h.app .state .as_ref() .expect("harness state") .panel .pointer_held, "a content press arms the gesture" ); } /// P2, pointer — **the row that shows a route cannot stand in for an /// effect.** A wheel carries only a delta; whether it becomes a /// viewport update, a panel event, a terminal event or nothing at /// all is `State`'s to decide. Here, over a plain document, it /// scrolls and re-declares the scoped viewport. #[test] fn a_wheel_over_a_document_declares_a_new_viewport() { let mut h = EffectHarness::new(); let event = WindowEvent::MouseWheel { device_id: DeviceId::dummy(), delta: MouseScrollDelta::LineDelta(0.0, -3.0), phase: TouchPhase::Moved, }; let step = h.feed(&event); assert_eq!( step.local, vec![LocalEffect::Scroll { top: WHEEL_LINES_PER_TICK as usize * 3 }], "a wheel scrolls locally" ); // NOT `.all()` alone — that is vacuously true on an empty // transcript, so an outbound-blind harness would pass this row. // Mutation M22 found exactly that. assert!( !step.outbound.is_empty(), "a document wheel must send something" ); assert!( step.outbound .iter() .all(|e| matches!(e, pmacs_protocol::FrontendEvent::Viewport { .. })), "a document wheel sends viewport updates and nothing else, got {:?}", step.outbound ); } /// P2, pointer — a wheel that resolves to **zero lines** is dropped /// by the body before any send. Same route, no effect: the /// distinction only exists once effects are observed. #[test] fn a_subtick_wheel_sends_nothing_at_all() { let mut h = EffectHarness::new(); let event = WindowEvent::MouseWheel { device_id: DeviceId::dummy(), delta: MouseScrollDelta::LineDelta(0.0, 0.0), phase: TouchPhase::Moved, }; let step = h.feed(&event); assert!(step.local.is_empty(), "{:?}", step.local); assert!( step.outbound.is_empty(), "a zero-line wheel must send nothing, got {:?}", step.outbound ); } /// A pixel inside the minimap band, and one inside the document /// text. Both are asserted by their rows before use, so a fixture /// whose geometry drifts fails loudly instead of quietly measuring /// the wrong surface. fn minimap_probe(h: &EffectHarness) -> (f64, f64) { let state = h.app.state.as_ref().expect("harness state"); let left = minimap_left(state.config.width).expect("the fixture has a minimap band"); (f64::from(left + 2.0), f64::from(MINIMAP_TOP + 4.0)) } fn document_probe(h: &EffectHarness) -> (f64, f64) { let state = h.app.state.as_ref().expect("harness state"); ( f64::from(state.text_left() + 8.0), f64::from(TEXT_TOP + 4.0), ) } fn move_pointer(h: &mut EffectHarness, (x, y): (f64, f64)) { h.feed(&WindowEvent::CursorMoved { device_id: DeviceId::dummy(), position: PhysicalPosition::new(x, y), }); } fn wheel(dx: f32, dy: f32) -> WindowEvent { WindowEvent::MouseWheel { device_id: DeviceId::dummy(), delta: MouseScrollDelta::LineDelta(dx, -dy), phase: TouchPhase::Moved, } } /// B6 — a wheel over the **minimap** scrolls the **document /// viewport**, the same effect a wheel over the text has. /// /// The minimap is not a scrollable surface of its own: it is a /// picture of the document, and turning the wheel over a picture of /// the document moves the document. Click and drag over it remain /// scrub, which is a different gesture on the same pixels. /// /// This is the routing half of B6. It reaches `apply_wheel` through /// `dispatch_window_event`, so the classifier, the accumulator and /// the local step are all production code here. /// /// *Mutation: give `WheelTarget::Minimap` its own inert arm ahead of /// the one that applies the local line step → this row. (Deleting it /// from that arm outright would not compile, so the mutation run is /// the compiling equivalent: the minimap reaches no line step.)* #[test] fn b6_a_wheel_over_the_minimap_scrolls_the_document_viewport() { let mut h = EffectHarness::new(); let probe = minimap_probe(&h); move_pointer(&mut h, probe); assert_eq!( h.app.classify_wheel_target(probe.0, probe.1), WheelTarget::Minimap, "setup: the probe pixel must be minimap, or this row measures \ the document twice" ); let step = h.feed(&wheel(0.0, 1.0)); assert_eq!( step.local, vec![LocalEffect::Scroll { top: WHEEL_LINES_PER_TICK as usize }], "one notch over the minimap moves the document one notch" ); assert!( !step.outbound.is_empty(), "a minimap wheel must re-declare the viewport, like any other \ document scroll" ); assert!( step.outbound .iter() .all(|e| matches!(e, pmacs_protocol::FrontendEvent::Viewport { .. })), "got {:?}", step.outbound ); } /// L5, GPU leg — a horizontal wheel moves the **viewport only**. /// /// Q#S1-11 ruled (B): carrying point would be a new wire operation, /// which 1b's non-protocol scope forbids. The TUI leg asserts point /// and selection directly; here the stronger statement is available /// — **the wire stays silent**, because on this frontend moving /// point means telling the daemon. /// /// *Mutation: have the horizontal leg send a cursor event → this /// row.* #[test] fn l5_a_horizontal_wheel_on_the_gpu_moves_neither_point_nor_the_wire() { let mut h = EffectHarness::with_document(&format!("{}\n", "wide ".repeat(120)).repeat(200)); { let buffer_id = h .app .state .as_ref() .expect("harness state") .current_buffer_id .expect("the harness stands in a buffer"); let state = h.app.state.as_mut().expect("harness state"); let _ = state.apply_attach_message(InstanceMessage::LineWrapFacts { buffer_id, wrap: false, }); assert_eq!(state.buffer.wrap(), Wrap::None, "setup: wrap is off"); } let document = document_probe(&h); move_pointer(&mut h, document); assert_eq!( h.app.classify_wheel_target(document.0, document.1), WheelTarget::Document, "setup: document text" ); let cursor_before = h.app.state.as_ref().expect("state").own_cursor; let step = h.feed(&wheel(1.0, 0.0)); assert!( h.app.state.as_ref().expect("state").code_scroll_left > 0.0, "setup: the notch must actually have scrolled, or this row \ passes by doing nothing" ); assert_eq!( h.app.state.as_ref().expect("state").own_cursor, cursor_before, "the horizontal wheel is a viewport gesture" ); assert!( step.outbound.is_empty(), "and it tells the daemon nothing: moving point here would be \ a wire operation, got {:?}", step.outbound ); } /// Replace the harness's document with a fresh buffer, through the /// production `BufferSnapshot` receiver. fn replace_the_buffer(h: &mut EffectHarness) { let text = "line\n".repeat(200); let doc = loro::LoroDoc::new(); doc.get_text(LORO_TEXT_CONTAINER) .insert(0, &text) .expect("insert snapshot text"); let state = h.app.state.as_mut().expect("harness state"); let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot { buffer_id: BufferId::next(), crdt_snapshot: doc.export(loro::ExportMode::Snapshot).expect("export"), }); } /// R4 — **the document's residual does not survive a buffer /// replacement.** /// /// The bank is viewport state about the document being shown. Left /// standing across a replacement it completes a notch in the /// successor that the user began in its predecessor — a jump with /// nothing on screen to explain it. The chrome residual is the same /// bank (chrome's owner IS the document's), so one reset serves /// both. /// /// *Mutation: omit `clear_document()` from the snapshot arm → this /// row.* #[test] fn r4_a_buffer_replacement_drops_the_documents_wheel_residual() { let mut h = EffectHarness::new(); let document = document_probe(&h); move_pointer(&mut h, document); assert_eq!( h.app.classify_wheel_target(document.0, document.1), WheelTarget::Document, "setup: document text" ); let step = h.feed(&wheel(0.0, 0.6)); assert!( step.local.is_empty(), "setup: 0.6 of a notch banks and does nothing yet: {:?}", step.local ); replace_the_buffer(&mut h); move_pointer(&mut h, document); let step = h.feed(&wheel(0.0, 0.6)); assert!( step.local.is_empty(), "the successor starts from zero: a notch begun in the \ previous document must not complete in this one, got {:?}", step.local ); // And the successor's own bank still works, so the row is not // passing by having broken accumulation outright. let step = h.feed(&wheel(0.0, 0.6)); assert_eq!( step.local, vec![LocalEffect::Scroll { top: WHEEL_LINES_PER_TICK as usize }], "0.6 + 0.6 within the successor is one notch" ); } /// R5 — **the minimap's residual is dropped by the same /// replacement, and by its own clear.** /// /// B6 gives the minimap an accumulator independent of the /// document's, so it needs its own reset. Two clears rather than /// one combined call, deliberately: a single "forgot to reset" /// would bite both legs and prove neither field is individually /// covered. /// /// *Mutation: omit `clear_minimap()` from the snapshot arm → this /// row, and not R4.* #[test] fn r5_a_buffer_replacement_drops_the_minimaps_wheel_residual() { let mut h = EffectHarness::new(); let minimap = minimap_probe(&h); move_pointer(&mut h, minimap); assert_eq!( h.app.classify_wheel_target(minimap.0, minimap.1), WheelTarget::Minimap, "setup: the minimap band" ); let step = h.feed(&wheel(0.0, 0.6)); assert!( step.local.is_empty(), "setup: 0.6 banks and does nothing yet: {:?}", step.local ); replace_the_buffer(&mut h); let minimap = minimap_probe(&h); move_pointer(&mut h, minimap); assert_eq!( h.app.classify_wheel_target(minimap.0, minimap.1), WheelTarget::Minimap, "setup: still the minimap after the replacement" ); let step = h.feed(&wheel(0.0, 0.6)); assert!( step.local.is_empty(), "the minimap's bank starts from zero in the successor too, \ got {:?}", step.local ); // And the successor's minimap bank still accumulates, so this // row cannot pass by the accumulator simply being broken — // which every "nothing happened" assertion above would accept. let step = h.feed(&wheel(0.0, 0.6)); assert_eq!( step.local, vec![LocalEffect::Scroll { top: WHEEL_LINES_PER_TICK as usize }], "0.6 + 0.6 over the successor's minimap is one notch of \ DOCUMENT scroll, which is what a minimap wheel moves" ); } /// B6 — the minimap banks into **its own** accumulator, so a /// part-notch over it cannot complete a notch over the document. /// /// This is the surface-switch case B1 exists to forbid, and the /// minimap is its sharpest instance precisely *because* both /// surfaces move the same viewport: sharing one bank would look /// harmless and produce a jump the user's last gesture does not /// explain. Two part-notches on different surfaces must stay two /// part-notches. /// /// The third step proves the row is not passing by measuring /// nothing: the same document bank, given the rest of its notch, /// does fire. /// /// *Mutation: map `WheelTarget::Minimap` to `ResidualOwner::Document` /// → this row, at the second step.* #[test] fn b6_a_part_notch_over_the_minimap_does_not_complete_one_over_the_document() { let mut h = EffectHarness::new(); let minimap = minimap_probe(&h); let document = document_probe(&h); move_pointer(&mut h, minimap); assert_eq!( h.app.classify_wheel_target(minimap.0, minimap.1), WheelTarget::Minimap, "setup: minimap pixel" ); let step = h.feed(&wheel(0.0, 0.6)); assert!( step.local.is_empty() && step.outbound.is_empty(), "0.6 of a notch is not a notch: {:?} {:?}", step.local, step.outbound ); move_pointer(&mut h, document); assert_eq!( h.app.classify_wheel_target(document.0, document.1), WheelTarget::Document, "setup: document pixel" ); let step = h.feed(&wheel(0.0, 0.6)); assert!( step.local.is_empty() && step.outbound.is_empty(), "the minimap's 0.6 must not have been waiting in the \ document's bank: {:?} {:?}", step.local, step.outbound ); // The document's own bank still works: 0.6 + 0.6 completes it. let step = h.feed(&wheel(0.0, 0.6)); assert_eq!( step.local, vec![LocalEffect::Scroll { top: WHEEL_LINES_PER_TICK as usize }], "the document's accumulator must still accumulate, or the \ step above proves nothing" ); } /// B6 — the minimap's **horizontal** axis is inert. /// /// It banks vertically like any other target, but a sideways notch /// over a fixed-width picture of the document has nothing to mean, /// so §2a's enumeration rules it inert. The contrast is the point: /// the identical event over document text does scroll sideways. /// /// *Mutation: drop the `!matches!(target, WheelTarget::Minimap)` /// guard from the horizontal leg → this row.* #[test] fn b6_a_horizontal_wheel_over_the_minimap_is_inert() { // Lines far wider than the viewport, so the saturated right // bound leaves somewhere to scroll. The default fixture's four // columns pin `max_left` to zero, which would make the contrast // below vacuous — the setup assertion caught exactly that. let mut h = EffectHarness::with_document(&format!("{}\n", "wide ".repeat(120)).repeat(200)); // Wrapping is on by default, and `scroll_by_columns` pins the // left edge to zero while it is — so with wrap left alone, BOTH // legs below would sit still and the row would report inertness // it never tested. Turned off through the daemon message that // production uses. { let buffer_id = h .app .state .as_ref() .expect("harness state") .current_buffer_id .expect("the harness stands in a buffer"); let state = h.app.state.as_mut().expect("harness state"); let _ = state.apply_attach_message(InstanceMessage::LineWrapFacts { buffer_id, wrap: false, }); assert_eq!( state.buffer.wrap(), Wrap::None, "setup: the wrap-off message must have landed on this buffer" ); } let minimap = minimap_probe(&h); let document = document_probe(&h); let left_of = |h: &EffectHarness| { h.app .state .as_ref() .expect("harness state") .code_scroll_left }; move_pointer(&mut h, minimap); assert_eq!( h.app.classify_wheel_target(minimap.0, minimap.1), WheelTarget::Minimap, "setup: the probe must be the minimap. Several other targets \ are horizontally inert for their own reasons — panel chrome \ banks nowhere at all — so a probe that drifted onto one \ would satisfy every assertion below without testing B6" ); let before = left_of(&h); let step = h.feed(&wheel(1.0, 0.0)); // **Inert, not merely unmoved.** An unchanged left edge alone // would still pass if the notch had produced a local effect or // put an event on the wire, so the transcript is asserted empty // beside it. assert!( step.local.is_empty() && step.outbound.is_empty(), "a horizontal notch over the minimap must do nothing at all: \ {:?} {:?}", step.local, step.outbound ); // Unchanged, not merely small: any real horizontal scroll is at // least one character advance, which is orders above this. assert!( (left_of(&h) - before).abs() < f32::EPSILON, "a horizontal notch over the minimap must not move the \ document sideways: {before} -> {}", left_of(&h) ); // The same event over text, to show the delta was real and the // row is not asserting that horizontal wheels do nothing at all. // // Its discriminator is the left edge, NOT the transcript: a // horizontal document scroll is local and silent, so this leg's // transcript is empty too. Only `code_scroll_left` separates the // two surfaces. move_pointer(&mut h, document); assert_eq!( h.app.classify_wheel_target(document.0, document.1), WheelTarget::Document, "setup: the contrast probe must be document text" ); h.feed(&wheel(1.0, 0.0)); assert!( left_of(&h) > before, "setup: the same notch over text must scroll, else the \ assertion above is vacuous" ); } /// P2, pointer — motion updates the cached pointer position, which /// is the state mutation the drag path later reads. It is not a /// `LocalEffect` variant because it is `State`-internal, so the row /// asserts it directly. #[test] fn cursor_motion_caches_the_pointer_position() { let mut h = EffectHarness::new(); let event = WindowEvent::CursorMoved { device_id: DeviceId::dummy(), position: PhysicalPosition::new(30.0, 40.0), }; h.feed(&event); assert_eq!( h.app.state.as_ref().expect("harness state").pointer_pos, Some((30.0, 40.0)) ); } /// B5 — the icon is re-applied on **every** motion, not only when /// divider hover flips. /// /// Crossing from text into the gutter changes the icon and does not /// touch `hover_divider`, so the old divider-change gate left the /// I-beam on screen for exactly the transitions B5 is about. The /// unit rows cannot see this: they call `desired_cursor_icon` /// directly and never exercise the gate. /// /// *Mutation: gate the call on `set_panel_divider_hover(..)` again → /// this row.* #[test] fn b5_the_icon_follows_motion_between_text_and_chrome() { let mut h = EffectHarness::new(); let (text_x, gutter_x, y) = { let state = h.app.state.as_mut().expect("harness state"); state.line_numbers = crate::LineNumberMode::Absolute; let text_left = state.text_left(); assert!( text_left > crate::TEXT_LEFT, "fixture: a gutter must exist for this row to discriminate" ); ( f64::from(text_left + 8.0), f64::from(crate::TEXT_LEFT.midpoint(text_left)), f64::from(crate::TEXT_TOP + 4.0), ) }; h.app.apply_cursor_moved(text_x, y); assert_eq!( h.app.state.as_ref().expect("state").last_cursor_icon, Some(winit::window::CursorIcon::Text), "motion into text sets the I-beam" ); assert!( !h.app.state.as_ref().expect("state").panel.hover_divider, "setup: divider hover stays false throughout, so a \ divider-gated apply would never run" ); h.app.apply_cursor_moved(gutter_x, y); assert_eq!( h.app.state.as_ref().expect("state").last_cursor_icon, Some(winit::window::CursorIcon::Default), "motion into the gutter clears it — the gate would have left \ the I-beam showing" ); } /// B4 END TO END — a middle press driven through /// `dispatch_window_event` produces **exactly one `Paste` carrying /// PRIMARY and nothing else**, and its release produces nothing at /// all. /// /// The two seam rows cannot see this: mutating /// `paste_source_for` or replacing the dispatch arm with a /// no-op leaves both of them green, because each asserts a function /// in isolation rather than the effect the gesture produces. /// /// The two selections carry **distinguishable** contents — a row /// whose PRIMARY and CLIPBOARD stubs said the same thing would pass /// with the wrong one read. /// /// **It asserts the WHOLE `Step`, not "no `Paste`".** Filtering for /// pastes lets any other outbound event through, so a gesture that /// also emitted something spurious would pass. #[test] fn b4_a_middle_press_sends_exactly_one_paste_carrying_primary() { use winit::event::{ElementState, MouseButton}; let mut h = EffectHarness::new(); if let Some(state) = h.app.state.as_mut() { state.set_test_selection(crate::PasteSource::Primary, b"PRIMARY-payload"); state.set_test_selection(crate::PasteSource::Clipboard, b"CLIPBOARD-payload"); } let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Middle)); // The harness's frontend id is whatever the handshake assigned; // the row is about the payload and the shape, not the id. let frontend_id = match step.outbound.first() { Some(pmacs_protocol::FrontendEvent::Paste { frontend_id, .. }) => *frontend_id, other => panic!("expected a Paste first, got {other:?}"), }; assert_eq!( step, Step { local: Vec::new(), outbound: vec![pmacs_protocol::FrontendEvent::Paste { frontend_id, data: b"PRIMARY-payload".to_vec(), }], }, "exactly one PRIMARY paste, no local effect, nothing else" ); let release = h.feed(&mouse_input(ElementState::Released, MouseButton::Middle)); assert_eq!( release, Step { local: Vec::new(), outbound: Vec::new() }, "the release does nothing at all; the gesture fires once, on \ the press" ); } /// B4's OFF-LINUX leg — the gesture is **completely inert**. /// /// B4 rules PRIMARY on Linux and rules nothing else, so off Linux a /// middle press must produce no effect of any kind — not a clipboard /// paste, not anything. /// /// **This row exists because no CI leg runs this crate's tests off /// Linux.** `cargo test -p pmacs-gpu` appears once in `ci.yml`, in /// the Ubuntu-only `gpu-render` job, so a `cfg`-gated row would /// assert the off-Linux contract nowhere that actually executes, and /// `paste_source_for(..).unwrap_or(PasteSource::Clipboard)` at the /// call site would pass everything. The platform is injected instead /// of read, so the branch runs here. /// /// *Mutation: `unwrap_or(PasteSource::Clipboard)` at the call site → /// this row.* #[test] fn b4_off_linux_a_middle_press_is_completely_inert() { use winit::event::{ElementState, MouseButton}; let mut h = EffectHarness::new(); h.app.test_force_non_linux = true; if let Some(state) = h.app.state.as_mut() { state.set_test_selection(crate::PasteSource::Primary, b"PRIMARY-payload"); state.set_test_selection(crate::PasteSource::Clipboard, b"CLIPBOARD-payload"); } let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Middle)); assert_eq!( step, Step { local: Vec::new(), outbound: Vec::new() }, "off Linux the gesture is inert: no paste of any selection, no \ local effect, nothing" ); } /// P2 — a button the frontend has no semantics for reaches no body: /// nothing local, nothing outbound. The counterpart to the routing /// row that calls it claimed-and-dropped. #[test] fn an_unused_button_produces_no_effect_of_any_kind() { let mut h = EffectHarness::new(); // `Back`, not `Middle`: B4 gave the middle PRESS a meaning, so // this row moved to a button that still has none rather than // being weakened to accommodate the new one. let step = h.feed(&mouse_input(ElementState::Pressed, MouseButton::Back)); assert_eq!( step, Step { local: Vec::new(), outbound: Vec::new() } ); } // --------------------------------------------------------------- // GUI arc 1a — PRODUCER REACHABILITY. // // These drive `App::apply_keyboard`, the real call site, not // `text_input_payload`. 1a's first review found the classifier // correct and the call site wrong: `TextInput` selection sat below // the intercept return, and a modal prompt or a focused terminal is // exactly what makes intercept true, so A7 and A8 were reachable // only when neither was present. A classifier test stays green // through that defect; these rows do not. // --------------------------------------------------------------- /// Multi-scalar text reaches the wire as `TextInput` **while the /// daemon is intercepting** — the state a modal prompt puts the /// session in. #[test] fn multi_scalar_text_is_sent_while_the_daemon_intercepts() { let mut h = EffectHarness::new(); // A minibuffer is up: `daemon_intercepts_keys` is true. h.app.state.as_mut().expect("harness state").dispatch_idle = false; let step = h.feed_keyboard(&Key::Character("e\u{301}".into()), Some("e\u{301}")); assert!( step.outbound.iter().any(|e| matches!( e, pmacs_protocol::FrontendEvent::TextInput { text, .. } if text == "e\u{301}" )), "an intercepting session must still get the whole commit, \ not a truncated Key; got {:?}", step.outbound ); assert!( !step .outbound .iter() .any(|e| matches!(e, pmacs_protocol::FrontendEvent::Key(_))), "and must NOT also get the first-scalar Key: {:?}", step.outbound ); } /// A4 — an IDLE Escape reaches the daemon and does not exit. /// /// "Idle" is the case that used to quit: with nothing intercepting, /// Escape destroyed the window instead of cancelling. Both halves /// are asserted, because either alone would pass a wrong /// implementation — sending Escape while also exiting, or not /// exiting while also sending nothing. #[test] fn a4_an_idle_escape_reaches_the_daemon_and_does_not_exit() { let mut h = EffectHarness::new(); // Establish idle explicitly. A fresh `State` starts with // `dispatch_idle` false — the daemon has not said otherwise yet // — so ASSERTING the precondition rather than setting it would // have tested the intercepting case under an idle name. It // failed exactly that way first. h.app.state.as_mut().expect("harness state").dispatch_idle = true; assert!( !h.app .state .as_ref() .expect("harness state") .daemon_intercepts_keys(), "precondition: nothing intercepts, which is the case that quit" ); let step = h.feed_keyboard(&Key::Named(NamedKey::Escape), None); assert!( step.outbound.iter().any(|e| matches!( e, pmacs_protocol::FrontendEvent::Key(k) if k.key == ProtocolKey::Escape )), "an idle Escape must reach the daemon: {:?}", step.outbound ); assert!( !step.local.contains(&LocalEffect::Exit), "and must NOT exit: {:?}", step.local ); } /// The complement, so the row above cannot pass by sending /// `TextInput` for everything: a SINGLE scalar while intercepting /// still travels as `Key`, which is §5 rule 4 and preserves mode /// keymaps and typed provenance. #[test] fn single_scalar_text_still_travels_as_key_while_intercepting() { let mut h = EffectHarness::new(); h.app.state.as_mut().expect("harness state").dispatch_idle = false; let step = h.feed_keyboard(&Key::Character("a".into()), Some("a")); assert!( step.outbound .iter() .any(|e| matches!(e, pmacs_protocol::FrontendEvent::Key(_))), "a single scalar stays a Key: {:?}", step.outbound ); assert!( !step .outbound .iter() .any(|e| matches!(e, pmacs_protocol::FrontendEvent::TextInput { .. })), "and must not become TextInput: {:?}", step.outbound ); } /// P2 — the harness records a transcript, and the transcript /// distinguishes every routed effect from the others and from an /// unclaimed event. **Two of these rows produce no outbound traffic /// whatsoever** (`Redraw`, `Exit`), which is the property that rules /// out a harness built on protocol traffic alone. #[test] fn the_harness_records_each_local_effect_in_order() { let events = [ WindowEvent::Resized(PhysicalSize::new(800, 600)), modifiers_changed(ModifiersState::SHIFT), WindowEvent::CursorMoved { device_id: DeviceId::dummy(), position: PhysicalPosition::new(4.0, 8.0), }, mouse_input(ElementState::Pressed, MouseButton::Left), // `Back`: this row is about ORDER, and it keeps a // semantics-free button so B4's new middle-press meaning // does not quietly become part of what it asserts. mouse_input(ElementState::Pressed, MouseButton::Back), WindowEvent::RedrawRequested, WindowEvent::Occluded(false), WindowEvent::CloseRequested, ]; let mut harness = RoutingHarness::default(); for event in &events { harness.feed(event); } assert_eq!( harness.transcript(), &[ Route::Lifecycle(LifecycleRoute::Resize { width: 800, height: 600, }), Route::Lifecycle(LifecycleRoute::Modifiers(ModifiersState::SHIFT)), Route::Pointer(PointerRoute::Moved { x: 4.0, y: 8.0 }), Route::Pointer(PointerRoute::Left(ElementState::Pressed)), Route::Pointer(PointerRoute::UnusedButton), Route::Lifecycle(LifecycleRoute::Redraw), Route::Unrouted, Route::Lifecycle(LifecycleRoute::Exit), ] ); } } impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.state.is_some() { return; } self.state = Some(State::new(event_loop, CONNECTING_TEXT)); if let Some(client) = self.attach_client.as_ref() && let Some(state) = self.state.as_mut() { state.set_frontend_id(client.frontend_id()); state.set_panel_wire(client.session_protocol_version()); } // Q#BP15a: the first declaration rides the first surface this // frontend actually has. The daemon needs columns before it can // paint a first panel frame, so this is sent WITHOUT a side window // — gating it on panel presence would deadlock the first open. self.flush_panel_geometry(GeometryTrigger::Surface); // 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()); state.set_panel_wire(client.session_protocol_version()); } self.attach_client = Some(client); self.flush_panel_geometry(GeometryTrigger::Surface); } 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()); } } } } for event in std::mem::take(&mut self.pending_events) { self.dispatch_app_event(event); } } /// P3 — the thin call-through. Every decision belongs to /// [`Self::dispatch_window_event`], which a headless test drives /// directly. What is left here is `event_loop.exit()` — the one /// statement no headless test can reach, because `ActiveEventLoop` /// cannot exist outside a live event loop. fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { if self.dispatch_window_event(&event) == EventOutcome::Exit { event_loop.exit(); } } /// 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) { if let Some(event) = defer_app_event(self.state.is_some(), &mut self.pending_events, event) { self.dispatch_app_event(event); } } } /// 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; /// Columns per horizontal wheel notch — B7's "three columns per wheel /// tick", the horizontal twin of [`WHEEL_LINES_PER_TICK`]. const WHEEL_COLUMNS_PER_TICK: f32 = 3.0; /// Which OS selection a paste gesture reads. /// /// X11 and Wayland carry two: the CLIPBOARD, written by an explicit /// copy, and the PRIMARY selection, written merely by selecting text. /// **They are different selections with different contents**, and the /// platform convention pairs them with different gestures. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] enum PasteSource { /// What Ctrl-V reads. Clipboard, /// What a middle click reads on Linux (GUI Stage 1b B4). Primary, } /// The selection a middle click pastes from on a given platform, or /// `None` where the gesture has no ruled meaning. /// /// **B4 rules PRIMARY on Linux, and rules nothing else.** Off Linux /// there is no PRIMARY selection, and the gesture was inert before this /// slice; making it paste the CLIPBOARD instead would be a new /// behaviour on every other platform that no framing approved. It stays /// inert, and a fallback needs framing and re-approval rather than a /// default chosen here. /// /// **The platform is a PARAMETER, not a `cfg!` read inside.** No CI leg /// runs this crate's tests on a non-Linux host — `cargo test -p /// pmacs-gpu` appears once, in the Ubuntu-only `gpu-render` job — so a /// decision baked in by `cfg!` would leave the off-Linux contract /// untested everywhere it actually runs. Taking it as an argument lets /// a row drive both outcomes here. const fn paste_source_for(is_linux: bool) -> Option { if is_linux { Some(PasteSource::Primary) } else { None } } /// The wire scroll kinds for a banked `(x, y)` tick count, in order. /// /// One event per whole tick: a single wheel notch that banks two ticks /// must move the receiver twice, and a receiver that coalesces is /// making its own decision rather than being handed a rounded one. fn wheel_kinds(ticks_x: i64, ticks_y: i64) -> Vec { let mut kinds = Vec::new(); let vertical = if ticks_y < 0 { ProtocolMouseKind::ScrollUp } else { ProtocolMouseKind::ScrollDown }; for _ in 0..ticks_y.unsigned_abs() { kinds.push(vertical); } let horizontal = if ticks_x < 0 { ProtocolMouseKind::ScrollLeft } else { ProtocolMouseKind::ScrollRight }; for _ in 0..ticks_x.unsigned_abs() { kinds.push(horizontal); } kinds } /// 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); // Inline-math slice (Q#MS7): the layout engine over the bundled // Latin Modern Math. Failure is surfaced once and disables only // the math path — spans keep rendering as source. let math_engine = match math_layout::MathLayout::new(math_layout::LATIN_MODERN_MATH) { Ok(engine) => Some(engine), Err(e) => { eprintln!("pmacs-gpu: bundled math font unusable ({e:?}); inline math disabled"); None } }; let math_budget = math_code_budget(fm); 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); // Inline-math slice (Q#MS6) — a renderer for the math glyph layer. let math_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); // Bottom panel Stage 2B-3 — the band's glyphs get their own layer // and their own clip for the same reason: they answer to the // daemon's panel grid, not to the document's gutter or wrapping. let panel_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()), ); // Declare the document's wrap mode instead of inheriting // cosmic-text's constructor default (`Wrap::WordOrGlyph`). // // `Glyph`, not `None`: `ui.line-wrap` defaults to `wrap`, so a // frontend that has not yet been told anything — or is talking // to a pre-v22 daemon that never will be — should already be in // the default mode. What changes versus the inherited default is // only the break rule, word to character, which is the // cross-frontend parity this stage buys. // // Declaring it is load-bearing rather than tidy: without it the // document runs on `WordOrGlyph` until some message happens to // change it, so a frontend talking to a pre-v22 daemon word // wraps forever while the grid renderer character wraps. buffer.set_wrap(&mut font_system, Wrap::Glyph); 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 { #[cfg(test)] render_calls: 0, 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, code_scroll_left: 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, last_cursor_icon: None, #[cfg(test)] test_selections: HashMap::new(), wheel_residuals: WheelResiduals::default(), edge_scroll_dir: None, edge_scroll_last: None, styled_redraw_deadline: None, hit_map_dirty: false, line_chunk_cache: Vec::new(), line_math_cache: Vec::new(), math_engine, math_budget, 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, math_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, panel: PanelBand::default(), panel_text_buffers: Vec::new(), panel_family: PanelFamily::Unsupported, panel_metrics_changed: false, last_terminal_size_sent: None, terminal_frame_error_latched: false, last_terminal_pointer_cell: None, terminal_text_buffers: Vec::new(), terminal_text_renderer, panel_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(); // Q#MS5/F4: the text applied above re-chunked under the OLD // caret; the effective caret only just moved. Suppression keys // on `own_cursor`, so re-run the compare — without this, a // typed char that lands the caret against a span boundary // renders one keystroke stale. self.refresh_math_suppression(); 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(); } else { // The incremental path deliberately skips `reshape` — and // skipped clause 3's clamp with it. A one-line edit can // shorten the widest line, which lowers // `widest − viewport`, so the origin has to come down here // too or a keystroke leaves the viewport past the end of // the text. self.clamp_code_scroll_left(); } 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> { self.read_os_selection(PasteSource::Clipboard) } /// Stub selection contents for tests, consulted by /// [`Self::read_os_selection`] before the OS clipboard. /// /// **A test seam in production code, and deliberately so.** B4's /// contract is *which selection* a middle click reads, and the two /// selections cannot be told apart through a real clipboard in a /// unit test — a row that asserts "a paste happened" passes with the /// wrong selection read. This is the smallest seam that lets the row /// assert the payload rather than the seam that chose it. #[cfg(test)] fn set_test_selection(&mut self, source: PasteSource, bytes: &[u8]) { self.test_selections.insert(source, bytes.to_vec()); } /// Read one named OS selection. /// /// GUI Stage 1b B4 needs the **PRIMARY** selection, which on Linux /// is a different selection from the clipboard with different /// contents. Reading the clipboard for a middle click would paste /// whatever was last explicitly copied instead of what is currently /// selected — a plausible-looking wrong answer, which is why B4's /// row asserts the source rather than that "a paste happened". fn read_os_selection(&mut self, source: PasteSource) -> Option> { #[cfg(test)] if let Some(bytes) = self.test_selections.get(&source) { return Some(bytes.clone()); } let clipboard = self.os_clipboard()?; let read = match source { PasteSource::Clipboard => clipboard.get_text(), #[cfg(target_os = "linux")] PasteSource::Primary => { use arboard::{GetExtLinux, LinuxClipboardKind}; clipboard .get() .clipboard(LinuxClipboardKind::Primary) .text() } #[cfg(not(target_os = "linux"))] PasteSource::Primary => clipboard.get_text(), }; match read { Ok(s) => Some(s.into_bytes()), Err(e) => { eprintln!("pmacs-gpu: {source:?} 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; // Stage 5 (Q#G2): the horizontal offset is viewport // state tied to the document being shown, so it resets // with the other two. Without this a buffer switch // inherits the PREVIOUS document's leftward viewport // and renders the new buffer scrolled sideways until a // cursor motion repairs it — a symptom nothing about // the new buffer explains. self.code_scroll_left = 0.0; // GUI Stage 1b R4/R5 — the wheel residuals for the // DOCUMENT and the MINIMAP live in this long-lived // `State` and outlive the buffer, so they reset here // for the same reason `code_scroll_left` does: a new // buffer must not inherit banked motion from the old // one. Two separate lines rather than one clear, so a // mutation that omits either is individually visible — // chrome shares the document's owner, so that one line // serves both. self.wheel_residuals.clear_document(); self.wheel_residuals.clear_minimap(); 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... with ONE exception since the // inline-math slice: a Selection endpoint is a Q#MS11 // suppression gate, so a selection change re-runs the // per-line compare when the slice could hold math // (no-op for every other decoration kind and for // math-free text). self.refresh_math_suppression(); 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(); // Q#MS5: the caret is a suppression input now. A // move that crosses a math-span boundary must // re-chunk the affected lines even when no scroll // or edit follows — the follow above only reshapes // on scroll, and a retained line under the stale // suppression state is the #120 edge. self.refresh_math_suppression(); 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, .. } => { let menu = if rows.is_empty() { None } else { Some(MenuLocal { rows, active, anchor_px: self.menu_anchor_px, }) }; self.menu = menu; // B5 — menu ownership changed with no pointer motion, so // the icon is re-derived here. This arm changes no // geometry, so it is the only application it needs. self.apply_panel_cursor_icon(); self.request_redraw(); None } // Q#MB1 — the minibuffer prompt/input/candidates. `prompt: // None` closes it. This is the FROZEN legacy variant, which // only a `12..=22` daemon sends; its candidates carry no // detail, so they become rows with `detail: None` and render // exactly as they did before v23. InstanceMessage::MinibufferPrompt { prompt, input, cursor, candidates, selected, total, } => { self.minibuffer = prompt.map(|prompt| MinibufferLocal { prompt, input, cursor, rows: candidates .into_iter() .map(|label| MinibufferRow { label, detail: None, }) .collect(), selected, total, }); self.request_redraw(); None } // Discovery Stage 2 — the v23 rows form of the same surface, // carrying an optional per-row detail (a command's // description). `prompt: None` closes it, and the close // arrives in THIS family because the daemon picks the family // per peer: a rows session closed by a legacy clear would // leave the dropdown on screen forever. InstanceMessage::MinibufferPromptRows { prompt, input, cursor, rows, selected, total, } => { self.minibuffer = prompt.map(|prompt| MinibufferLocal { prompt, input, cursor, rows, 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(); // Q#BP2S1 / A2B-2: the panel's shaped runs were measured at // the old advance, and the CELL total may be identical while // the pixels behind it are not. The reshape is here; the new // declaration is the caller's, because only it can send. self.rebuild_panel_text_buffers(); self.panel_metrics_changed = true; self.current_buffer_id .and_then(|bid| self.viewport_send_if_changed(bid)) } InstanceMessage::TerminalFrame(frame) => { self.apply_terminal_frame(frame); None } InstanceMessage::LineWrapFacts { buffer_id, wrap } => { self.apply_line_wrap(buffer_id, wrap); None } InstanceMessage::PanelFrame(payload) => { // The band changes the DOCUMENT's pixel height, so a panel // that appears, disappears, or changes row count has to // reshape the document buffers. A content-only frame keeps // the same inset and needs only a repaint — reshaping every // live panel frame would put document work on the panel's // ordinary repaint path. let band_before = self.band_inset(); if self.apply_panel_payload(payload) && !self.reshape_if_panel_band_changed(band_before) { self.request_redraw(); } 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(); // A retained band is the daemon's projection of a window that is no // longer being updated. Leaving it on screen beside a disconnect // notice is the same "frozen, live-looking surface" the terminal arm // above exists to prevent. self.exit_panel_band(); 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; // B1's disposal, terminal half. Re-entering the SAME terminal // buffer would otherwise inherit the bank, because the owner // key is the buffer id and it has not changed. self.wheel_residuals.clear_terminals(); } /// Drop the band and every cache behind it. /// /// The geometry declaration goes too: the next session is a new session, /// its epochs start from scratch, and a retained declaration would /// describe a peer that is gone. fn exit_panel_band(&mut self) { self.panel = PanelBand::default(); self.panel_text_buffers.clear(); } /// 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 = document_text_bottom(self.config.height, self.fm, self.band_inset()) - origin_y; crate::terminal::cell_viewport( width, height, self.mono_advance(), self.fm.code_line_height(), ) } // ----------------------------------------------------------------- // Bottom panel band (Stage 2B-3) // ----------------------------------------------------------------- /// Whether this session negotiated the panel wire. /// /// Set once from the negotiated session version, never from the /// `Hello` baseline: the baseline stays at the compatibility floor /// forever, so reading it here would leave the band permanently dark. fn set_panel_wire(&mut self, session_protocol_version: u32) { self.panel_family = PanelFamily::from_session_version(session_protocol_version); } /// The band inset the document boundary is computed from. /// /// Routed through [`PanelBand::presented`] rather than re-deriving /// "is a panel visible" here, because a second derivation of that /// predicate is how the renderer and the retained state drift apart. fn band_inset(&self) -> PanelBandInset { self.panel .presented() .map_or(PanelBandInset::ABSENT, |frame| { PanelBandInset::installed(frame.size.rows, self.fm) }) } /// Reshape after a panel transition changed the document's bottom. /// /// Both directions terminate here: accepting/removing a frame in /// `apply_attach_message`, and invalidating a retained frame by advancing /// its geometry epoch. Content-only panel frames keep the same inset and /// deliberately avoid the document reshape cost. fn reshape_if_panel_band_changed(&mut self, before: PanelBandInset) -> bool { if self.band_inset() == before { return false; } self.sync_buffer_dimensions(); self.reshape(); true } /// The panel band's content rectangle in surface pixels: /// `(x, y, width, height)`, cells only — the divider sits above `y`. fn panel_content_rect(&self) -> Option<(f32, f32, f32, f32)> { let frame = self.panel.presented()?; let band = PanelBandInset::installed(frame.size.rows, self.fm); let cells_px = band.px() - self.fm.divider_height(); if cells_px <= 0.0 { return None; } let top = document_text_bottom(self.config.height, self.fm, band) + self.fm.divider_height(); // Origin x = 0 and the FULL surface width, matching the declaration. // Any fractional right-edge remainder past the last whole column is // band background: it maps to no cell and emits no `PanelPointer`, // which `hit_test_cell`'s column bound already enforces. Some((0.0, top, self.config.width as f32, cells_px)) } /// The divider strip: paint geometry AND hit geometry, one rect. /// /// Deliberately the same value for both. The framing decided a 4 px /// strip precisely so the pointer has a usable target, and deriving /// the hover band separately from the painted rule is how the two come /// to disagree by a pixel that the user can see but not grab. fn panel_divider_rect(&self) -> Option<(f32, f32, f32, f32)> { let frame = self.panel.presented()?; let band = PanelBandInset::installed(frame.size.rows, self.fm); Some(( 0.0, document_text_bottom(self.config.height, self.fm, band), self.config.width as f32, self.fm.divider_height(), )) } /// The stable normal-face advance the geometry declaration uses /// (framing §5.3, A2B-3). /// /// **Never [`Self::mono_advance`].** That falls back to the first /// shaped glyph of the *document* buffer when no `FontFacts` probe has /// been applied, which would make the panel's column count /// document-dependent: two frontends with identical metrics showing /// different files would derive different `total.cols`, and the same /// frontend's panel width would change when its first glyph did. /// /// `None` when the family shapes no width — the caller declares zero /// usable geometry rather than reaching for a document sample. fn panel_probe_advance(&mut self) -> Option { let metrics = Metrics::new(self.fm.code_font_size(), self.fm.code_line_height()); let family = self.resolved_family.clone(); probe_mono_advance(&mut self.font_system, &family, metrics) } /// This surface's whole-cell capacity as the daemon's layout model /// sees it (Q#BP15a's pixel→cell conversion). /// /// Zero-sized on any degenerate input, which is the fail-closed arm /// parent 41 requires: the daemon treats zero columns as /// non-presentable and the panel hides, rather than a non-finite /// metric producing an absurd row count and an oversized allocation. fn declared_cell_total(&mut self) -> (CellSize, Option) { let Some(advance) = self.panel_probe_advance() else { return (CellSize::new(0, 0), None); }; let height = (geometry_capacity_bottom(self.config.height, self.fm) - TEXT_TOP).max(0.0); // **Full surface width from x = 0.** The panel grid is not inset by // the document's `TEXT_LEFT` or gutter — those are document padding, // and the band is a separate surface spanning the frame (parent // framing Q#BP15a: "`total.cols` describes the full-width panel grid // beginning at x=0; document `TEXT_LEFT`/gutter padding is // unrelated"). Deducting `TEXT_LEFT` here under-declares columns and // leaves a strip the daemon never fills. let width = self.config.width as f32; let total = crate::terminal::panel_cell_capacity( width, height, advance, self.fm.code_line_height(), ) .unwrap_or_else(|| CellSize::new(0, 0)); (total, Some(advance)) } /// The advance every panel cell computation must use: the one behind the /// current declaration. /// /// `None` before a declaration exists — which is also when /// [`PanelBand::presented`] is `None`, so no painter or hit test can be /// reached without it. fn panel_cell_advance(&self) -> Option { self.panel .declared_advance .filter(|advance| advance.is_finite() && *advance > 0.0) } /// Advance the geometry declaration if this trigger calls for one, and /// return what the caller must send. /// /// The decision lives here and the *send* lives at the seam that owns /// the attach client, so the whole state machine — dedup, exhaustion, /// the latch — is reachable without a daemon. fn next_geometry_declaration(&mut self, trigger: GeometryTrigger) -> Option<(u64, CellSize)> { if !self.panel_family.carries_panel() || self.panel.exhausted { return None; } let band_before = self.band_inset(); let (total, advance) = self.declared_cell_total(); if trigger == GeometryTrigger::Surface && self.panel.geometry_epoch != 0 && self.panel.declared == Some(total) { return None; } let Some(next) = self.panel.geometry_epoch.checked_add(1) else { // Fail closed, and LATCH. Retaining the last declaration is not // fail-closed: if the surface then resizes, the daemon would // keep painting a panel sized to a frame that no longer exists. // Dropping the retained frame alone is not enough either — an // old `Present` whose epoch still matched would resurrect a // band under geometry this frontend has disowned. self.panel.exhausted = true; self.panel.frame = None; self.panel.plan = None; self.panel.drag = None; self.panel.hover_divider = false; self.panel.declared_advance = None; self.reshape_if_panel_band_changed(band_before); return None; }; self.panel.geometry_epoch = next; self.panel.declared = Some(total); self.panel.declared_advance = advance; // Advancing the epoch makes a retained frame stop being // `presented()` until the daemon answers the new declaration. // That removes its band after resize/font handling has already // performed its own reshape, so settle the final visibility change. self.reshape_if_panel_band_changed(band_before); Some((next, total)) } /// Apply an inbound `PanelFrame` payload. /// /// Returns `true` when the retained panel payload changed, so the caller /// can distinguish a repaint/reflow from an atomic rejection or duplicate. /// /// Validation is atomic: a rejected frame leaves the retained one /// exactly as it was, because `PanelFrame::validate` is pure and runs /// before any state is touched. fn apply_panel_payload(&mut self, payload: PanelFramePayload) -> bool { if self.panel.exhausted { // A latched session has disowned its geometry for good, so a // payload answering it describes nothing this frontend can // present. Retaining the frame anyway would leave `presented()` // as the only thing standing between a disowned declaration and // a painted band, and would spend a reshape on every arriving // frame for the rest of the session. return false; } match payload { // §5b G8d — a mapped frame is accepted ONLY by a session // that negotiated the mapped family. Retention is ATOMIC on // refusal: nothing about the previous frame, its generation // or the pointer state is touched on the way out. PanelFramePayload::PresentMapped { .. } if self.panel_family != PanelFamily::Mapped => { false } PanelFramePayload::Absent => { // Authoritative removal, and always safe. Note this does // NOT clear the geometry declaration: the frontend's frame // capacity is unchanged by a panel closing, and discarding // it would force a needless re-declaration before the next // open. let had = self.panel.presented().is_some(); self.panel.frame = None; self.panel.plan = None; self.panel.drag = None; self.panel.hover_divider = false; self.panel.pointer_held = false; self.panel.last_pointer_cell = None; self.panel.gesture_last_content_cell = None; self.panel.last_pointer_generation = None; // B1's disposal half: a residual banked against a // surface that no longer exists must go with it. The // bank is keyed by `BufferId`, which distinguishes // panel A from panel B — but not a panel closed and // REOPENED on the same persistent buffer, where the // successor would inherit a notch the user began in a // panel that is gone. self.wheel_residuals.clear_panels(); had } // §5b G8b — a mapped session REJECTS the legacy family // rather than painting a band it cannot safely hit-test. A // frame with no mapping identity is one whose cells cannot // be inverted safely, so accepting it would reintroduce the // hole from the receiving side. // Gated on the POSITIVE family, not on "not mapped". // `Unsupported` is neither, and a `!= Mapped` test let it // through — a session below `PANEL_MIN_VERSION` accepting a // band it never negotiated. `carries_panel()` guards the // geometry declaration, not this seam. PanelFramePayload::Present(_) if self.panel_family != PanelFamily::Legacy => false, PanelFramePayload::PresentMapped { frame, mapping_generation, } => { // §5b — zero is the wire's uninitialised value and is // refused like any mismatch, and the generation is // NONDECREASING: a delayed lower frame must not roll // this frontend's authority backward. if mapping_generation == 0 || self .panel .mapping_generation .is_some_and(|held| mapping_generation < held) { return false; } if let Err(error) = frame.validate() { eprintln!("pmacs-gpu: rejecting invalid mapped panel frame: {error}"); return false; } // A duplicate frame at the SAME generation does no work. // A duplicate at a HIGHER one still updates authority: // the daemon has re-keyed the mapping, and echoing the // old generation would have every gesture refused. if self.panel.frame.as_ref() == Some(&frame) && self.panel.mapping_generation == Some(mapping_generation) { return false; } // NOTE: the gesture-latch reset on an identity change is // R-d, owned by `panel-pointer-replay`. It is not // duplicated here — two branches resetting the same // latch would conflict at the rebase and neither would // own the contract. let plan = TerminalPaintPlan::build_grid( frame.size, &frame.cells, frame.cursor, Self::terminal_palette(), ); self.panel.frame = Some(frame); self.panel.mapping_generation = Some(mapping_generation); self.panel.plan = Some(plan); self.rebuild_panel_text_buffers(); true } PanelFramePayload::Present(frame) => { if let Err(error) = frame.validate() { eprintln!("pmacs-gpu: rejecting invalid panel frame: {error}"); return false; } if self.panel.frame.as_ref() == Some(&frame) { // A duplicate does no work — not even a reshape. return false; } // Parent 48 R-d: a held gesture belongs to the presentation // it began on. `Absent` clears the latch, but a // `Present` → `Present` REPLACEMENT did not, so a press on // panel A could emit a drag or release for B with no B // press — and acceptance 49 cannot reject that, because the // event carries B's CURRENT epochs. Geometry counts too: a // font or scale change moves `geometry_epoch` while // `panel_epoch` holds, and the gesture would resume under a // new grid. // // Only on a CHANGE of identity. A panel repaints constantly // during a drag, and resetting on every frame would make // selection impossible. // // Compared against the RETAINED frame, never `presented()`: // that accessor filters on `geometry_epoch == self.panel // .geometry_epoch`, and a geometry change advances the field // FIRST, so by the time the matching frame arrives // `presented()` is already `None` and an `is_some_and` // predicate skips the reset — exactly the case D2 covers. let identity_changed = self.panel.frame.as_ref().is_some_and(|current| { current.panel_epoch != frame.panel_epoch || current.geometry_epoch != frame.geometry_epoch }); if identity_changed { self.panel.pointer_held = false; self.panel.last_pointer_cell = None; self.panel.gesture_last_content_cell = None; } let plan = TerminalPaintPlan::build_grid( frame.size, &frame.cells, frame.cursor, Self::terminal_palette(), ); self.panel.frame = Some(frame); self.panel.plan = Some(plan); self.rebuild_panel_text_buffers(); true } } } /// Reshape one cosmic-text buffer per planned panel run. /// /// One buffer per RUN for the same reason terminal mode does it: a /// row-wide buffer would let a wide or cluster glyph's shaped advance /// decide where the next column starts, and panel columns belong to /// the daemon's grid, not to the shaper. fn rebuild_panel_text_buffers(&mut self) { let Some(plan) = self.panel.plan.as_ref() else { self.panel_text_buffers.clear(); return; }; let metrics = Metrics::new(self.fm.code_font_size(), self.fm.code_line_height()); // The declaration's advance, never the document's: a run shaped to a // different cell width than the daemon counted columns with drifts // one column further off across the row. let Some(advance) = self.panel_cell_advance() else { self.panel_text_buffers.clear(); return; }; let family = self.resolved_family.clone(); let runs: Vec<(String, f32, bool, bool)> = 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); 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.panel_text_buffers = buffers; } /// Pixel rectangle of a cell run inside the panel band. fn panel_run_rect(&self, run: crate::terminal::CellRun) -> Option<(f32, f32, f32, f32)> { let (ox, oy, _, _) = self.panel_content_rect()?; let advance = self.panel_cell_advance()?; let line = self.fm.code_line_height(); Some(( ox + run.start_col as f32 * advance, oy + run.row as f32 * line, (run.end_col - run.start_col) as f32 * advance, line, )) } /// The band's quad batch: the divider strip, cell backgrounds, and the /// panel caret, drawn under the band's glyphs. #[allow( clippy::too_many_lines, reason = "one band's complete quad batch: divider, cell backgrounds, every straight underline form, caret" )] fn panel_quad_vertex_bytes(&self) -> Vec { let mut rects = Vec::new(); if let Some((x, y, w, h)) = self.panel_divider_rect() { rects.push(MinimapRect { x, y, w, h, color: self.face_wash_or("ui.divider", DIVIDER_RGBA), }); } if let Some(plan) = self.panel.plan.as_ref() && self.panel.presented().is_some() { let window_bg = Self::terminal_palette().default_bg; for bg in &plan.backgrounds { if bg.color == window_bg { continue; } if let Some((x, y, w, h)) = self.panel_run_rect(bg.run) { rects.push(MinimapRect { x, y, w, h, color: rgb_to_quad(bg.color, 1.0), }); } } // Only a FOCUSED panel paints its caret. The producer includes // `cursor` for a passive panel too — it is the window's real // point, and the daemon does not suppress it — so painting it // unconditionally puts a second insertion caret on screen and // makes focus ownership visually ambiguous. `focused` is exactly // the presentation bit Q#BP14b reserves for this. // Straight underline forms as fixed-cell quads, exactly as the // terminal path does; curly rides the squiggle pipeline below, // which owns the sine wave. Dropping these silently loses every // diagnostic and styled-terminal underline inside the band. for underline in &plan.underlines { if underline.style == UnderlineStyle::Curly { continue; } let Some((x, y, w, h)) = self.panel_run_rect(underline.run) else { continue; }; 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 => { 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 dash_x = x; while dash_x < x + w { let dash_w = (period * duty).min(x + w - dash_x); rects.push(MinimapRect { x: dash_x, y: baseline, w: dash_w, h: thickness, color, }); dash_x += period; } } UnderlineStyle::Single => rects.push(MinimapRect { x, y: baseline, w, h: thickness, color, }), UnderlineStyle::Curly | UnderlineStyle::None => {} } } if let Some(cursor) = plan.cursor && self.panel.presented().is_some_and(|frame| frame.focused) && let Some((x, y, w, h)) = self.panel_run_rect(cursor) { rects.push(MinimapRect { x, y, w, h, color: TERMINAL_CURSOR_RGBA, }); } } if rects.is_empty() { return Vec::new(); } rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } /// Curly underlines inside the band, on the squiggle pipeline — the same /// split the terminal path makes, because the sine wave belongs to that /// pipeline and a quad cannot express it. fn panel_squiggle_vertex_bytes(&self) -> Vec { let Some(plan) = self.panel.plan.as_ref() else { return Vec::new(); }; if self.panel.presented().is_none() { return Vec::new(); } let rects: Vec = plan .underlines .iter() .filter(|underline| underline.style == UnderlineStyle::Curly) .filter_map(|underline| { let (x, y, w, h) = self.panel_run_rect(underline.run)?; Some(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) } /// Which panel cell a surface pixel is over, if any (Q#BP16). /// /// Returns `None` outside the band's content rect, which is what keeps /// a document gesture from being reported as a panel one. fn panel_hit_test(&self, x: f32, y: f32) -> Option { let frame = self.panel.presented()?; let (ox, oy, w, h) = self.panel_content_rect()?; if x < ox || x >= ox + w || y < oy || y >= oy + h { return None; } crate::terminal::hit_test_cell( x, y, (ox, oy), self.panel_cell_advance()?, self.fm.code_line_height(), frame.size, ) } /// Classify a pointer pixel against the band. /// /// The divider is tested before the cells because it sits directly above /// them and a gesture on the strip is a resize, not a selection. fn classify_pointer_surface(&self, x: f32, y: f32) -> PointerSurface { if self.panel_divider_contains(x, y) { return PointerSurface::PanelDivider; } let Some((ox, oy, w, h)) = self.panel_content_rect() else { return PointerSurface::Elsewhere; }; if x < ox || x >= ox + w || y < oy || y >= oy + h { return PointerSurface::Elsewhere; } match self.panel_hit_test(x, y) { Some(coord) => PointerSurface::PanelCell(coord), None => PointerSurface::PanelBackground, } } /// The gesture kind a panel motion carries: a held left button makes it a /// drag, and that distinction is the whole of panel selection — `Move` /// never focuses or claims, while every non-`Move` gesture activates the /// panel first. fn panel_motion_kind(&self) -> ProtocolMouseKind { if self.panel.pointer_held { ProtocolMouseKind::Drag(ProtocolMouseButton::Left) } else { ProtocolMouseKind::Move } } /// The cell a release belongs to: the one under the pointer while it is /// still in the band, else the last cell the gesture reported. /// /// A panel selection drag routinely ends past the band's edge, and /// dropping that release leaves the daemon holding a button down forever. fn panel_release_cell(&self, x: f32, y: f32) -> Option { // Parent 48 R-c/R-c2: `Up` is the load-bearing crossing event — a // gesture whose release is dropped leaves the daemon holding a // button down forever. It is therefore always sent, and always at // a CONTENT coordinate: chrome and outside-the-band both fall back // to where the gesture last legitimately pointed, which is set at // press time so a release with no intervening motion still has one. match self.classify_pointer_surface(x, y) { PointerSurface::PanelCell(coord) if !self.panel_cell_is_chrome(coord) => Some(coord), _ => self.panel.gesture_last_content_cell, } } /// Whether `coord` is the band's MODE-LINE row (parent 48 R-c). /// /// The daemon projects panel content as `rows - 1` and paints the last /// row as chrome, but `panel_hit_test` reports across the whole frame, /// so a hit is not automatically a content cell. fn panel_cell_is_chrome(&self, coord: CellCoord) -> bool { self.panel .presented() .is_some_and(|frame| coord.row + 1 >= frame.size.rows) } /// Whether a panel motion at `coord` carries anything new, and latch it. /// /// Sub-cell motion resolves to the same cell and says nothing the daemon /// can act on. Without this, pixel-rate motion becomes pixel-rate wire /// traffic and every one of those is a daemon-side gesture — the same /// reason the terminal path dedupes. fn panel_motion_is_new(&mut self, coord: CellCoord) -> bool { // §5b G9b — keyed by generation as well as cell. Deliberately // read here rather than reset from the frame path: resetting on // every accepted repaint would re-arm within one generation and // bring pixel-rate traffic back, and the dedupe would stop // being a dedupe. if self.panel.last_pointer_cell == Some(coord) && self.panel.last_pointer_generation == self.panel.mapping_generation { return false; } self.panel.last_pointer_cell = Some(coord); self.panel.last_pointer_generation = self.panel.mapping_generation; true } /// Arm or disarm the panel's left-button gesture, re-arming the motion /// dedupe either way. fn set_panel_pointer_held(&mut self, held: bool) { self.panel.pointer_held = held; self.panel.last_pointer_cell = None; // The termination fallback dies with the gesture (parent 48 R-c2). // Safe in both directions: the press path rewrites it immediately // after arming, and a release READS it before this runs. self.panel.gesture_last_content_cell = None; self.panel.last_pointer_generation = None; } /// Begin a divider drag at surface pixel `y`, if the pointer is on the /// strip. Returns whether a drag was started. fn begin_panel_drag(&mut self, x: f32, y: f32) -> bool { if !self.panel_divider_contains(x, y) { return false; } let Some(frame) = self.panel.presented() else { return false; }; self.panel.drag = Some(PanelDrag { panel_epoch: frame.panel_epoch, geometry_epoch: frame.geometry_epoch, start_rows: frame.size.rows, start_y: y, sent_rows: frame.size.rows, }); true } /// End any live divider drag. fn end_panel_drag(&mut self) -> bool { self.panel.drag.take().is_some() } /// The row request a live drag at pixel `y` implies, or `None` when /// there is no drag, no presented panel, or the request is unchanged. /// /// The drag's own epochs are checked against the panel that is on /// screen NOW: a gesture that outlives its presentation is dropped /// rather than applied to the successor, which is the same rule the /// daemon enforces on receipt. Both sides check because neither may /// depend on the other having done it. fn panel_drag_request(&mut self, y: f32) -> Option { let drag = self.panel.drag?; let frame = self.panel.presented()?; if frame.panel_epoch != drag.panel_epoch || frame.geometry_epoch != drag.geometry_epoch { self.panel.drag = None; return None; } let line = self.fm.code_line_height(); if !line.is_finite() || line <= 0.0 { return None; } // Dragging the divider UP grows the panel, so a negative pixel // delta is a positive row delta. let delta_rows = ((drag.start_y - y) / line).round(); if !delta_rows.is_finite() { return None; } let rows = (i64::from(drag.start_rows) + delta_rows as i64).max(1); let rows = u32::try_from(rows).unwrap_or(u32::MAX); if rows == drag.sent_rows { return None; } Some(PanelResizeRequest { geometry_epoch: drag.geometry_epoch, panel_epoch: drag.panel_epoch, rows, }) } /// Record that a row request was actually sent, so re-crossing the same /// row boundary does not re-send it. fn note_panel_drag_sent(&mut self, rows: u32) { if let Some(drag) = self.panel.drag.as_mut() { drag.sent_rows = rows; } } /// Apply the cursor icon [`Self::desired_cursor_icon`] chose to the /// real window. /// /// **Three outcomes, not two**, since GUI Stage 1b's B5: /// `RowResize` on the divider strip, `Text` over document text /// content, and the default arrow otherwise. The divider half is /// driven from the same `hover_divider` bit the hit test sets, so /// the icon cannot advertise a drag target the press would miss. /// /// Idempotent: it writes only when the icon actually changed, which /// is what makes calling it on every pointer motion cheap. fn apply_panel_cursor_icon(&mut self) { let icon = self.desired_cursor_icon(); if self.last_cursor_icon == Some(icon) { return; } self.last_cursor_icon = Some(icon); if let Some(window) = &self.window { window.set_cursor(icon); } } /// The cursor icon for the current pointer position. /// /// **One owner, deliberately.** GUI Stage 1b's B5 adds an I-beam /// over text content, and §2a's CORRECTION 3 is why it lands here /// rather than at a site of its own: this function's `else` branch /// writes `Default` unconditionally, so a separate I-beam writer /// would be **clobbered by it** on the next motion. The divider's /// `RowResize`, B5's `Text` and the `Default` fallback are decided /// together or not at all. /// /// Order matters: the divider outranks the I-beam, because the /// divider strip is a drag handle and is never text. fn desired_cursor_icon(&self) -> winit::window::CursorIcon { if self.panel.hover_divider { winit::window::CursorIcon::RowResize } else if self.pointer_over_text_content() { winit::window::CursorIcon::Text } else { winit::window::CursorIcon::Default } } /// Whether the pointer is over **document text content** — B5's /// "text content only". /// /// Excluded, each for its own reason: the **gutter**, which is left /// of `text_left` and is chrome rather than text; the **minimap**, /// which is a scrub surface; the **panel band**, which owns its own /// pixels; the **status band** and everything below the document's /// text bottom; and anything right of the text bounds. /// /// Geometric rather than a byte hit-test: an I-beam belongs over the /// text *area*, including the blank space past a short line's end, /// and a byte test would flicker the cursor along a ragged right /// margin. fn pointer_over_text_content(&self) -> bool { // An open context menu covers the document and owns the // pointer; its pixels are chrome however text-like whatever is // painted beneath them may be. if self.menu.is_some() { return false; } let Some((x, y)) = self.pointer_pos else { return false; }; let (x, y) = (x as f32, y as f32); if self.in_minimap_band(f64::from(x), f64::from(y)) { return false; } if !matches!( self.classify_pointer_surface(x, y), PointerSurface::Elsewhere ) { return false; } let bottom = document_text_bottom(self.config.height, self.fm, self.band_inset()); x >= self.text_left() && x < self.text_bounds_right() as f32 && y >= TEXT_TOP && y < bottom } /// Consume the "a font/scale change invalidated the declaration" flag. fn take_panel_metrics_changed(&mut self) -> bool { std::mem::take(&mut self.panel_metrics_changed) } /// Update divider hover, reporting whether the cursor icon must change. fn set_panel_divider_hover(&mut self, hovering: bool) -> bool { if self.panel.hover_divider == hovering { return false; } self.panel.hover_divider = hovering; true } /// Whether a surface pixel is on the divider strip — the exact rect /// that gets painted. fn panel_divider_contains(&self, x: f32, y: f32) -> bool { self.panel_divider_rect() .is_some_and(|(rx, ry, rw, rh)| x >= rx && x < rx + rw && y >= ry && y < ry + rh) } /// 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, self.band_inset()).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.horizontal_follow(byte); self.request_redraw(); } /// Widest display line **in the whole document**, in columns — /// B7's upper-bound input. /// /// **It must not read `self.buffer.lines`.** That holds only the /// visible byte slice plus overscan (`rebuild_code_slice`, session /// S1), so a bound taken from it excludes every off-screen line: /// horizontal scrolling would clamp prematurely and the bound would /// change as the view moved vertically. B3 asks for the widest /// display line of the document, so this reads `current_text`. /// /// **Cost is O(document) per call**, and this sits on the wheel /// path. That is a real risk against this project's wall-clock /// budget rows and is recorded rather than pre-optimised: a cache /// needs an invalidation key, and the wrong key is a worse defect /// than a measurable scan. /// /// **Which "display line" this measures is a boundary B3's witness /// must settle.** This counts SOURCE-TEXT display columns — tab /// stops and Unicode width — and therefore excludes rendered /// projections such as inline adornments and math substitutions, /// which can occupy a different width on screen than the bytes they /// stand for. That is consistent with the TUI-derived column rule /// the two frontends share, and it is a choice, not an oversight: /// "widest display line" is readable the other way. Recorded here /// so the witness states which meaning governs rather than /// discovering it. fn widest_display_columns(&self) -> u32 { pmacs_protocol::columns::widest_line_columns(&self.current_text) } /// GUI Stage 1b B3/B7: move the horizontal origin by whole columns. /// /// The bound is B7's, stated exactly: `0 ..= widest − viewport`, /// **saturating at zero** for buffers narrower than the viewport. /// Clamping at the widest line's *full* width would let the origin /// pass every glyph and leave the viewport blank. /// /// **Wrap pins the origin to zero** (lifetime clause 5): a wrapped /// buffer has nothing past the right edge, so no horizontal origin /// may survive. /// /// **This frontend keeps no authority flag**, and the difference /// from the TUI is deliberate. There, `horizontal_follow` runs on /// every paint and would drag the origin back to the caret, so a /// latch is the only thing that can outrank it. Here the follow /// runs only through `ensure_caret_painted`, which Q#F6's /// painted-before policy skips whenever the caret is off screen — /// and a caret the user has scrolled away from is off screen. The /// preservation is **structural**: there is no follow to outrank. /// /// A flag was carried here for a while, written in four places and /// read in none. Giving it a reader would have duplicated the /// painted-before policy and needed a cursor baseline of its own to /// avoid suppressing genuine cursor movement, so it was removed /// rather than completed. The contract is behavioral; the two /// frontends are not required to share a representation. fn scroll_by_columns(&mut self, columns: i64) { if self.buffer.wrap() != Wrap::None { self.code_scroll_left = 0.0; return; } let advance = self.mono_advance(); let width = self.text_bounds_right() as f32 - self.text_left(); if advance <= 0.0 || width <= 0.0 { return; } let viewport_cols = (width / advance).floor().max(0.0) as u32; let max_left = self.widest_display_columns().saturating_sub(viewport_cols); let current = (self.code_scroll_left / advance).round().max(0.0) as i64; let next = (current + columns).clamp(0, i64::from(max_left)); if next == current { return; } self.code_scroll_left = next as f32 * advance; } /// Move `code_scroll_left` so the caret's column is on screen /// (Stage 5, framing Q#G2 — automatic only). /// /// The horizontal mirror of `scroll_to_cursor`, and deliberately /// the same shape as the TUI's `horizontal_follow`: scroll only far /// enough to bring the caret back inside, so a caret already /// visible never moves the view. With no explicit scroll commands, /// every horizontal viewport move originates here. /// /// Runs AFTER `normalize_code_scroll` because it reads the caret's /// laid-out x, which the vertical normalization can change. /// /// **The decision is `pmacs_protocol::scroll::follow_left`**, the /// same function `src/editor.rs` calls, so the two frontends cannot /// choose different edges for the same cursor. That rule is stated /// in columns; this is where the conversion happens, and Q#G3 is /// what makes it exact — a non-monospace code font is rejected /// before it can reach layout, so every advance is the same width /// and `px / advance` is a column count rather than an estimate. /// /// The result is re-multiplied rather than kept in pixels, which /// **snaps the offset to the column grid**. That is the point: it /// is what makes "the same first visible character in both /// frontends" true rather than approximately true. fn horizontal_follow(&mut self, byte: u64) { // A wrapped buffer has nothing past the right edge; the offset // is pinned to 0 by `apply_line_wrap` and must stay there. if self.buffer.wrap() != Wrap::None { self.code_scroll_left = 0.0; return; } let Some((code_x, _top, _h)) = self.code_byte_px(byte) else { return; }; let advance = self.mono_advance(); let width = self.text_bounds_right() as f32 - self.text_left(); if advance <= 0.0 || width <= 0.0 { return; } // `floor` for the width — a half-visible trailing column is not // a column you can read — and `round` for the two positions, // which are exact multiples of the advance up to f32 // accumulation error. let cols = (width / advance).floor().max(0.0) as u32; let cursor_col = (code_x / advance).round().max(0.0) as u32; let left_col = (self.code_scroll_left / advance).round().max(0.0) as u32; let next = pmacs_protocol::scroll::follow_left(left_col, cursor_col, cols); self.code_scroll_left = next as f32 * advance; } /// 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** screen↔code transform (Stage 5, framing §1.1). /// /// A code-relative x — what `code_byte_px`, the decoration geometry /// and the math/completion origins all produce — becomes a screen x /// here and nowhere else. Written once because the alternative is /// five call sites that can disagree, and a disagreement between /// the caret and the glyphs it sits among is invisible until /// somebody scrolls. fn code_x_to_screen(&self, code_x: f32) -> f32 { self.text_left() - self.code_scroll_left + code_x } /// The exact inverse, for hit testing. fn screen_x_to_code(&self, screen_x: f32) -> f32 { screen_x - self.text_left() + self.code_scroll_left } /// **The** code clip rectangle's left edge (Stage 5, framing §1.1). /// /// glyphon honors `TextBounds`, so the document `TextArea` clips /// itself. **The manual quad and squiggle renderers do not** — /// nothing stopped them painting into the gutter, and nothing /// needed to, because before this stage no code-relative x could be /// negative. Every code-relative painter must now intersect with /// this. fn code_clip_left(&self) -> f32 { if self.line_numbers.is_on() { self.text_left().floor() } else { 0.0 } } /// Crop a code-relative rect `[x, x + w)` in SCREEN coordinates to /// the code clip's left edge, returning the surviving `(x, w)`. /// `None` when the rect lies wholly inside the gutter. /// /// **Cropping, not dropping**, because these rects are washes: a /// selection or search band running in from off the left edge must /// still paint the part that IS on screen. Dropping it whole is the /// exact boundary defect the TUI painter had before Stage 4. fn crop_to_code_clip_left(&self, screen_x: f32, w: f32) -> Option<(f32, f32)> { let left = self.code_clip_left(); let right = screen_x + w; (right > left).then(|| { let x = screen_x.max(left); (x, right - x) }) } /// Whether a code-relative rect `[x, x + w)` in SCREEN coordinates /// survives the code clip's left edge — the same boundary as /// [`Self::crop_to_code_clip_left`], for the callers (the caret) /// that want a yes/no rather than a cropped rect. Delegating keeps /// one rule: a caret the crop would discard is never painted. fn survives_code_clip_left(&self, screen_x: f32, w: f32) -> bool { self.crop_to_code_clip_left(screen_x, w).is_some() } /// 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 { // Stage 5: the EXACT inverse of `code_x_to_screen`, so a click // lands on the glyph under the pointer at any offset. The // gutter clamp stays in SCREEN space and is applied first — a // click in the gutter band means "the first visible column", // which after scrolling is the offset, not column 0. if self.line_numbers.is_on() && (x as f32) < self.text_left() { return self.code_scroll_left; } self.screen_x_to_code(x as f32) } /// The shaped slice's math substitutions, read back from the /// per-line chunk caches and rebased to slice-relative offsets — /// the spacer text and suppressed range are both in the cached /// `MathBox` chunks, so the hit map reproduces the shaped state /// exactly instead of re-planning under a possibly-newer caret. fn cached_math_subs_for_slice(&self, vstart: u64, vend: u64) -> Vec { let mut subs = Vec::new(); if self.math_engine.is_none() { return subs; } let (top, ranges) = self.slice_line_ranges(vstart, vend); if top != self.shaped_top || ranges.len() != self.line_chunk_cache.len() { // The caches describe a different slice; a math-blind map // (boxes unclickable at worst) beats a wrong one. return subs; } for (i, &(ls, _)) in ranges.iter().enumerate() { let base = ls - vstart; for chunk in &self.line_chunk_cache[i] { if let ChunkSource::MathBox { start, end } = chunk.source { subs.push(MathSubstitution { span: math_parse::MathSpan { start: (base + start) as usize, end: (base + end) as usize, }, spacer: chunk.text.clone(), boxed: math_layout::MathBox { width: 0.0, ascent: 0.0, descent: 0.0, items: Vec::new(), }, }); } } } subs } 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; // B1': the hit map must see the SAME math suppressions the // shaped lines carry, so the slice-wide substitution list // is read back from the per-line caches (never recomputed // — a caret that moved since the last reshape must not // make the map disagree with the glyphs), rebased from // line-relative to slice-relative offsets. let subs = self.cached_math_subs_for_slice(vstart, vend); let rich = clipped_chunks_for_range( &self.current_text, &self.current_spans, &self.current_adornments, vstart, vend, &subs, ); 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, self.band_inset(), ) } /// 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, self.band_inset(), )?; let centered = target.saturating_sub( estimated_visible_lines(self.config.height, self.fm, self.band_inset()) / 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() || shaped_idx >= self.line_math_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, math) = 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.line_math_cache[shaped_idx] = math; 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, MathLineState) { let (subs, mut state) = self.math_plan_for_line(line_start, content_end); let chunks = clipped_chunks_for_range( &self.current_text, &self.current_spans, &self.current_adornments, line_start, content_end, &subs, ); state.placed = placed_math_boxes(&chunks, &subs); (chunks, state) } /// The line's math suppression plan (Q#MS3/Q#MS4). Detection runs /// here — the chunk-build path, never the edit path — and the gate /// reads the EFFECTIVE caret (`own_cursor`, which optimistic edits /// predict forward) plus the own-selection endpoints, so /// suppression cannot flap during an unconfirmed edit (framing /// Q#MS5/F4). Any failure — parse, layout, fit, degenerate spacer — /// leaves that span as source (Q#MS8). fn math_plan_for_line( &self, line_start: u64, content_end: u64, ) -> (Vec, MathLineState) { let mut subs = Vec::new(); let mut state = MathLineState::default(); let Some(engine) = self.math_engine.as_ref() else { return (subs, state); }; let Some(line) = self .current_text .get(line_start as usize..content_end as usize) else { return (subs, state); }; if !line.as_bytes().contains(&b'$') { return (subs, state); } let spans = math_parse::detect_math_spans(line); if spans.is_empty() { return (subs, state); } let gates = self.math_gate_positions(line_start, content_end); let advance = self.mono_advance(); for span in spans { let gated = gates .iter() .any(|&p| span.start as u64 <= p && p <= span.end as u64); state.gates.push((span, gated)); if gated { continue; } let Ok(node) = math_parse::parse(&line[span.interior()]) else { continue; }; let Ok(boxed) = engine.layout(&node, self.fm.code_font_size()) else { continue; }; let Some(fitted) = math_layout::fit_to_line(&boxed, self.math_budget.0, self.math_budget.1) else { continue; }; let spacer = math_layout::spacer_for_width(fitted.width, advance); if spacer.is_empty() { continue; } subs.push(MathSubstitution { span, spacer, boxed: fitted, }); } (subs, state) } /// Line-relative byte positions whose presence inside a span /// unsuppresses it: the effective caret and both own-selection /// endpoints (Q#MS5 generalised by Q#MS11 — "you are addressing /// this text" and "you see this text" are the same condition). fn math_gate_positions(&self, line_start: u64, content_end: u64) -> Vec { let mut gates = Vec::new(); let mut push = |byte: u64| { if byte >= line_start && byte <= content_end { gates.push(byte - line_start); } }; if let Some(own) = self.own_cursor && Some(own.buffer_id) == self.current_buffer_id { push(own.byte); } for d in &self.current_decorations { if d.kind == DecorationKind::Selection { push(d.range.start); push(d.range.end); } } gates } /// Whether a retained line's cached gate bits match the current /// effective caret/selection — the suppression input to the /// line-reuse predicate beside content and styling (Q#MS5; a /// retained line shaped under the opposite suppression state is /// the #120 stale-mirror failure). Content is unchanged on every /// reuse path, so the cached span set is authoritative and only /// the gate bits can differ. fn math_gates_match(&self, line_start: u64, content_end: u64, state: &MathLineState) -> bool { if state.gates.is_empty() { return true; } let gates = self.math_gate_positions(line_start, content_end); state.gates.iter().all(|&(span, was_gated)| { let now = gates .iter() .any(|&p| span.start as u64 <= p && p <= span.end as u64); now == was_gated }) } /// Caret or selection motion can flip a span's Q#MS5 gate without /// any content change, which the frame-driven refresh paths never /// see; re-run the per-line chunk compare when the visible slice /// could hold math at all. Cheap when it cannot (one `$` scan). fn refresh_math_suppression(&mut self) { if self.math_engine.is_none() || self.terminal.is_some() { return; } let (vstart, vend) = self.view_range; let Some(slice) = self.current_text.get(vstart as usize..vend as usize) else { return; }; if slice.as_bytes().contains(&b'$') { self.refresh_changed_lines(); } } /// 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 old_math: Vec> = std::mem::take(&mut self.line_math_cache) .into_iter() .map(Some) .collect(); let mut lines = Vec::with_capacity(ranges.len()); let mut cache = Vec::with_capacity(ranges.len()); let mut math = 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() && j < old_math.len() { // Reuse is sound only when suppression state is a // third invariant beside content and styling // (Q#MS5): a caret-follow scroll lands here with a // caret that may have crossed a span boundary, and // a retained line shaped under the opposite // suppression state is the #120 stale-mirror // failure (framing acceptance 11). if !self.math_gates_match(ls, ce, old_math[j].as_ref()?) { return None; } Some(( old_lines[j].take()?, old_cache[j].take()?, old_math[j].take()?, )) } else { None } }); if let Some((line, chunks, m)) = reused { any_reused = true; lines.push(line); cache.push(chunks); math.push(m); } else { let (chunks, m) = self.chunks_for_line(ls, ce); lines.push(line_from_chunks(&chunks, &self.resolved_family)); cache.push(chunks); math.push(m); } } self.buffer.lines = lines; self.line_chunk_cache = cache; self.line_math_cache = math; 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.line_math_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, m) = 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; } // Always current, even when the chunks are unchanged: an // unsuppressible span's gate bit can flip with no chunk // difference, and a stale bit would defeat the reuse // comparison later. self.line_math_cache[i] = m; } 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. /// `&mut self` because the wrapped branch asks cosmic-text where two /// bytes actually landed, and laying a line out shapes it. That is /// the point rather than a wart: the alternative is a per-frame /// cached `(first_visible, last_visible)` pair, which is a value /// maintained beside the layout and free to disagree with it — /// the same shape as the `code_wrap` shadow field this lane already /// removed once. fn compose_status_runs(&mut 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(" "); } if self.buffer.wrap() == Wrap::None { readout.push_str(&format_scroll_indicator( self.scroll_top, estimated_visible_lines(self.config.height, self.fm, self.band_inset()), self.current_line_starts.len(), cursor_row, )); } else { // Wrapping makes the line-space formatter wrong, not merely // imprecise: it compares `visible` (VISUAL rows that fit) // against `total_lines` (SOURCE lines), so a document whose // lines each wrap to three rows reports `Bot` from a third // of the way down. This is not new in this lane — the GPU // has always wrapped — but the lane is where it became // nameable, because `ui.line-wrap` is now what decides which // formula applies. // // The percentage comes from bytes because there is no row // total to take it from: only the viewport slice is shaped, // so rows below it were never laid out and counting them // arithmetically would disagree with the breaks cosmic-text // actually chose. Same rule as the TUI, from // `pmacs_protocol::scroll`. let byte_len = self.current_text.len() as u64; let byte_pos = self .own_cursor .filter(|own| self.current_buffer_id == Some(own.buffer_id)) .map_or_else( // No cursor of ours in this buffer: the viewport's // own top is the honest position, matching the // `cursor_row = self.scroll_top` fallback above. || { self.current_line_starts .get(self.scroll_top) .copied() .unwrap_or(0) }, |own| own.byte.min(byte_len), ); let first_visible = self.code_byte_painted(0); let last_visible = self.code_byte_painted(byte_len); readout.push_str(&render_scroll_position(pmacs_protocol::scroll::classify( first_visible, last_visible, byte_pos, byte_len, ))); } 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: status_band_top(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. /// /// Discovery Stage 2: a row with a `detail` renders `label detail`, /// the same two-space form the completion dropdown already uses. A /// row without one renders the bare label, so a file-path or /// buffer-name prompt looks exactly as it did before v23. fn refresh_mb_buffer(&mut self) { let text = self.minibuffer.as_ref().map_or_else(String::new, |mb| { mb.rows .iter() .map(|row| match row.detail.as_deref() { Some(detail) => format!("{} {detail}", row.label), None => row.label.clone(), }) .collect::>() .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 = status_band_top(self.config.height, self.fm); mb_dropdown_window( mb.rows.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 = status_band_top(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 = document_text_bottom(self.config.height, self.fm, self.band_inset()); if y >= bottom || y + line_height <= TEXT_TOP { return None; } // Stage 5: an anchor scrolled off the LEFT is out of view the // same way one below the band is. Returning `None` HIDES the // popup — it does not close it. The daemon owns completion // state and its key handling; closure is `CompletionPopup { // anchor: None }`, which is the daemon's to send. Scrolling // back brings the popup straight back, and a viewport lane must // not quietly redefine when a completion ends. // // **A POINT predicate, not `survives_code_clip_left`.** An // anchor is a position between glyphs — it has no horizontal // extent of its own, and the popup it places is drawn to its // RIGHT. The first version of this passed `line_height` as the // width, which is a vertical dimension standing in for a // horizontal one: an anchor up to a line-height left of the // gutter then "survived", and `completion_dropdown_rect` has no // left clamp (it bounds `ax` against the right margin only), so // that x reached the popup's left edge and painted over the // line numbers. // // That absent clamp stays absent deliberately. This predicate // is what guarantees `ax >= code_clip_left()`, and a second // clamp downstream would be a duplicate of the same rule — the // failure mode this stage's whole shared-transform design // exists to avoid. `completion_dropdown_rect` is witnessed // against it instead. let screen_x = self.code_x_to_screen(x); if screen_x < self.code_clip_left() { return None; } Some((screen_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 = document_text_bottom(self.config.height, self.fm, self.band_inset()); 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, self.band_inset()).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()); let mut math = Vec::with_capacity(ranges.len()); for &(ls, ce) in &ranges { let (chunks, m) = self.chunks_for_line(ls, ce); lines.push(line_from_chunks(&chunks, &self.resolved_family)); cache.push(chunks); math.push(m); } self.buffer.lines = lines; self.line_chunk_cache = cache; self.line_math_cache = math; 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; } } } /// Honor a wrap mode for `buffer_id` (protocol v22). /// /// The document buffer has never set a wrap mode, so it has been /// running on cosmic-text's constructor default, /// `Wrap::WordOrGlyph` — word wrap that nobody chose. This makes the /// mode explicit in both directions and settles it on /// **`Wrap::Glyph`**: character wrap is what the grid renderer can /// implement identically without pulling UAX #14 into it, and it is /// what Emacs does by default. GUI users lose word wrap; that is a /// deliberate, documented trade for the two frontends agreeing. /// /// Changing wrap reflows the whole document, exactly like a font /// change, so the retained scroll anchor is repaired through /// `normalize_code_scroll` rather than left pointing at a row that /// no longer exists. fn apply_line_wrap(&mut self, buffer_id: BufferId, wrap: bool) { // Only the buffer on screen can be reflowed; the mode is // buffer-local, so a message for anything else is not ours to // apply. The daemon resends on buffer switch precisely so this // stays correct rather than needing a per-buffer cache here. if self.current_buffer_id != Some(buffer_id) { return; } let want = if wrap { Wrap::Glyph } else { Wrap::None }; // Compare against the BUFFER, not a shadow field. A cached copy // can disagree with what cosmic-text actually holds — and when // it does, the short-circuit turns a real mode change into a // silent no-op. Reading the authority cannot drift from it. if self.buffer.wrap() == want { return; } self.buffer.set_wrap(&mut self.font_system, want); // Stage 5 (Q#G2): RESET, not merely ignore. A wrapped buffer has // nothing past the right edge, so an offset is meaningless here // — but leaving it parked would surface a stale viewport the // instant the buffer toggled back to `truncate`, before any // cursor motion. The TUI's `horizontal_follow` zeroes it on the // same branch for the same reason. self.code_scroll_left = 0.0; self.reshape(); } 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; // B5 — **the one place the cursor icon is re-derived after // geometry.** The I-beam is decided against a boundary that // moves without the pointer: `text_left` with the line-number // mode's digit width, the text clip with minimap presence, // panel appearance, window resize and font metrics. Every one // of those settles by reshaping, so re-deriving here covers // them all at once instead of leaving each new geometry path to // remember a call it will not remember. self.apply_panel_cursor_icon(); // GUI Stage 1b, lifetime clause 3 — the horizontal origin is // **clamped** by the same settling, for the same reason. self.clamp_code_scroll_left(); self.request_redraw(); } /// Bring the horizontal origin back inside `0 ..= widest − viewport` /// (lifetime clause 3). /// /// Geometry and content both move that bound: a **wider** viewport /// lowers it, and so does a shortened widest line. /// /// **Nothing else brings the origin down.** `horizontal_follow` /// would, but it runs only when the caret is painted (Q#F6's /// painted-before policy) — and the caret is not painted precisely /// when the user has scrolled it off screen, which is exactly the /// state a stale origin survives in. Measured before this existed: /// after a scroll to the right bound at 640px and a widen to /// 1600px, the origin stayed 960px past the new maximum, leaving /// most of the viewport blank with the text off its left edge. /// /// Gated on a non-zero origin because it scans the document for the /// widest line, and every reshape paying for that would be a steep /// price for a state most windows are never in. fn clamp_code_scroll_left(&mut self) { if self.code_scroll_left <= 0.0 { return; } if self.buffer.wrap() != Wrap::None { self.code_scroll_left = 0.0; return; } let advance = self.mono_advance(); let width = self.text_bounds_right() as f32 - self.text_left(); if advance <= 0.0 || width <= 0.0 { return; } let cols = (width / advance).floor().max(0.0) as u32; let max_left = self.widest_display_columns().saturating_sub(cols); let current = (self.code_scroll_left / advance).round().max(0.0) as u32; if current > max_left { self.code_scroll_left = max_left as f32 * advance; } } /// 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 = (document_text_bottom(self.config.height, fm, self.band_inset()) - 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 Q#MS10 fit budget follows the code metrics; the reshape // below rebuilds every line's math plan against it. self.math_budget = math_code_budget(self.fm); // 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) { // GUI 1-pre P2 — a repaint is a LOCAL effect with no outbound // trace, and headless there is no surface either: every path // below returns without observable consequence. The counter is // the only way a harness can witness that the redraw arm ran, // and it is incremented before the surface check for exactly // that reason. Test-only, so production carries nothing. #[cfg(test)] { self.render_calls += 1; } 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(); // Inline-math ink (Q#MS6): glyph mini-buffers plus fraction-rule // quads. The rules ride the bg quad batch AFTER the decoration // washes, so a selection/search wash under a rendered box never // paints over its fraction bar; the glyphs get their own layer // in the code z-slot below. let (math_buffers, math_rules) = if terminal_mode { (Vec::new(), Vec::new()) } else { self.build_math_paint() }; // 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(rects_to_vertex_bytes( &math_rules, self.config.width, self.config.height, )); bg_vertices.extend(self.status_band_vertex_bytes()); // Bottom panel Stage 2B-3 — the divider strip, the band's cell // backgrounds, and the panel caret. Added in BOTH modes: the // document may itself be a terminal while a panel is open, so // gating this on `terminal_mode` would make the band vanish // exactly when it is hosting the output the user asked for. bg_vertices.extend(self.panel_quad_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 mut squiggle_vertices = if terminal_mode { self.terminal_squiggle_vertex_bytes() } else { self.squiggle_vertex_bytes() }; // The band's curly underlines, in BOTH modes for the same reason its // quads are: the document may itself be a terminal while a panel is // open. squiggle_vertices.extend(self.panel_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 = status_band_top(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