pmacs/pmacs-gpu/src/main.rs

8883 lines
357 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! pmacs-gpu — GPU/GUI frontend for pmacs.
//!
//! Two run modes:
//!
//! - **Hello-world** (no `--attach` argument; session 2 default).
//! Opens a window and renders "hello, pmacs" in the bundled
//! `JetBrains` Mono. Used to confirm the wgpu/winit/glyphon stack
//! without depending on a daemon.
//! - **Attach** (`--attach <unix-socket-path>`; session 3+). Connects
//! to a running pmacs daemon, negotiates `semantic_render +
//! crdt_replica`, imports the daemon's `BufferSnapshot` into a
//! local loro replica, sends a `Viewport` back to request scoped
//! styling, and consumes the `StyleSpans` stream — rendering the
//! rope with per-span colors via cosmic-text's `set_rich_text`.
//! Live `CrdtOp` updates apply to the doc; subsequent `StyleSpans`
//! frames re-style.
//!
//! See `docs/pmacs-gpu-design.md` for the arc framing. Phase A's
//! adversarial-verification framing applies from session 4 forward;
//! findings classified per rule (iii) at surface-time.
//!
//! The bundled font is `JetBrains` Mono Regular, distributed under
//! the SIL Open Font License 1.1 (see `fonts/OFL.txt`).
mod attach;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use glyphon::{
Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache,
TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
};
use loro::{ContainerTrait, ExportMode};
use pmacs_protocol::{
AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CompletionPopupRow, CrdtOp,
Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage,
InstanceSignal, Key as ProtocolKey, LineNumberMode, MenuPromptRow, Modifiers, PointerKind,
SelectionSnapshot, StyleSegment, StyleSpan,
cell::{Color as CellColor, Style as CellStyle},
is_builtin_pair_char,
};
use wgpu::MultisampleState;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::keyboard::{Key, NamedKey};
use winit::window::{Window, WindowId};
use crate::attach::{AttachClient, AttachEvent};
/// Bundled font (SIL Open Font License 1.1 — see `fonts/OFL.txt`).
const JETBRAINS_MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono-Regular.ttf");
/// Initial window size in logical pixels.
const INITIAL_WIDTH: u32 = 800;
const INITIAL_HEIGHT: u32 = 200;
/// Color the surface clears to before text renders.
const BG: wgpu::Color = wgpu::Color {
r: 0.05,
g: 0.05,
b: 0.07,
a: 1.0,
};
const TEXT_LEFT: f32 = 16.0;
const TEXT_TOP: f32 = 16.0;
/// Caret bar width in px, and its color (bright, near-opaque — drawn
/// over the text so it reads as the active insertion point). Session
/// B1.
const CARET_WIDTH: f32 = 2.0;
const CARET_COLOR: [f32; 4] = [0.90, 0.90, 0.96, 0.90];
/// Extra source lines shaped beyond the visible window so a 1-line
/// scroll doesn't always re-slice and the bottom partial line renders
/// (Q#S3). Kept small — overscan is wasted shaping.
const SCROLL_OVERSCAN: usize = 2;
const TEXT_RIGHT_GAP: f32 = 10.0;
const MINIMAP_WIDTH: f32 = 48.0;
const MINIMAP_RIGHT: f32 = 12.0;
const MINIMAP_TOP: f32 = 12.0;
const MINIMAP_BOTTOM: f32 = 12.0;
const MINIMAP_MIN_SURFACE_WIDTH: u32 = 180;
const MINIMAP_MIN_THUMB_HEIGHT: f32 = 18.0;
const MINIMAP_H_PAD: f32 = 3.0;
const MINIMAP_CODE_COLS: f32 = 100.0;
const MINIMAP_MIN_STROKE_WIDTH: f32 = 1.5;
const MINIMAP_MAX_LINE_STROKE_HEIGHT: f32 = 2.0;
const CODE_LINE_HEIGHT: f32 = 22.0;
/// Font size of the code buffer (and the line-number gutter, so their
/// line heights match and rows align).
const 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 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 STATUS_BAND_HEIGHT: f32 = 26.0;
const STATUS_BAND_BG: [f32; 4] = [0.105, 0.105, 0.145, 1.0];
const STATUS_TEXT_PAD: f32 = 10.0;
const STATUS_FONT_SIZE: f32 = 13.0;
const STATUS_LINE_HEIGHT: f32 = 18.0;
// Context menu popup (Q#CM1). One row per item/separator; width tracks
// the widest label (estimated from a fixed per-char advance, which the
// code font's monospacing makes good enough for hit-testing + the bg
// quad to agree).
const MENU_ROW_HEIGHT: f32 = 22.0;
const MENU_FONT_SIZE: f32 = 14.0;
const MENU_LINE_HEIGHT: f32 = 22.0;
const MENU_PAD_X: f32 = 12.0;
const MENU_CHAR_W: f32 = 8.4;
const MENU_MIN_WIDTH: f32 = 140.0;
const MENU_MAX_WIDTH: f32 = 380.0;
const MENU_BG: [f32; 4] = [0.16, 0.16, 0.20, 0.98];
const MENU_SELECTED_BG: [f32; 4] = [0.20, 0.40, 0.66, 1.0];
const MENU_SEPARATOR_BG: [f32; 4] = [0.30, 0.30, 0.36, 1.0];
// Minibuffer completion dropdown (Q#MB1). A vertical list anchored just
// above the bottom band, best match at the top; reuses the menu popup's
// colors. Width tracks the widest candidate (measured from the shaped
// buffer).
const MB_DROP_ROW_HEIGHT: f32 = 20.0;
const MB_DROP_FONT_SIZE: f32 = 13.0;
const MB_DROP_LINE_HEIGHT: f32 = 20.0;
const MB_DROP_PAD_X: f32 = 10.0;
const MB_DROP_MIN_WIDTH: f32 = 160.0;
const MB_DROP_MAX_WIDTH: f32 = 480.0;
/// 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) -> Option<(usize, usize)> {
if n == 0 {
return None;
}
let max_rows = (band_top / 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<f32>,
@location(0) color: vec4<f32>,
};
@vertex
fn vs_main(
@location(0) pos: vec2<f32>,
@location(1) color: vec4<f32>,
) -> VertexOut {
var out: VertexOut;
out.pos = vec4<f32>(pos, 0.0, 1.0);
out.color = color;
return out;
}
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
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<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
};
@vertex
fn vs_main(
@location(0) pos: vec2<f32>,
@location(1) uv: vec2<f32>,
@location(2) color: vec4<f32>,
) -> VertexOut {
var out: VertexOut;
out.pos = vec4<f32>(pos, 0.0, 1.0);
out.uv = uv;
out.color = color;
return out;
}
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
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<f32>(in.color.rgb, in.color.a * alpha);
}
";
const SQUIGGLE_VERTEX_STRIDE: wgpu::BufferAddress = 32;
const SQUIGGLE_VERTEX_ATTRS: [wgpu::VertexAttribute; 3] =
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4];
/// Text the hello-world (and attach-pre-snapshot / attach-failed)
/// modes render. Once the daemon's `BufferSnapshot` arrives the
/// rendered text becomes the rope contents instead.
const HELLO_TEXT: &str = "hello, pmacs";
/// Container id the daemon uses on its loro `LoroDoc` for the
/// buffer's text. Must match `pmacs::crdt::CrdtState`'s container
/// name (`"body"`).
const LORO_TEXT_CONTAINER: &str = "body";
/// Custom events delivered to the winit event loop. The reader thread
/// in `attach.rs` forwards each decoded `InstanceMessage` through the
/// `EventLoopProxy<AppEvent>` it was handed by `connect()`; the main
/// thread dispatches them in `user_event` below.
#[derive(Debug)]
pub enum AppEvent {
/// A message or disconnect notification from the attach reader
/// thread.
Attach(AttachEvent),
}
/// CLI mode derived from argv.
#[derive(Debug, Clone)]
enum Mode {
/// `pmacs-gpu` (no args): inert hello-world.
HelloWorld,
/// `pmacs-gpu --attach <socket>`: connect + render the daemon's
/// rope.
Attach { socket: PathBuf },
}
/// Number of decimal digits in `n` (for `n >= 1`); allocation-free. Sizes
/// the line-number gutter (UX gutter arc). Mirrors the TUI's
/// `pmacs::window::decimal_digits` — kept local since pmacs-gpu doesn't
/// depend on the `pmacs` crate.
fn decimal_digits(mut n: usize) -> u32 {
let mut d = 1u32;
while n >= 10 {
n /= 10;
d += 1;
}
d
}
fn main() {
env_logger::init();
let mode = parse_args(std::env::args().skip(1).collect());
let event_loop = EventLoop::<AppEvent>::with_user_event()
.build()
.expect("create winit event loop");
let proxy = event_loop.create_proxy();
let mut app = App {
mode,
proxy: Some(proxy),
state: None,
attach_client: None,
modifiers: winit::keyboard::ModifiersState::empty(),
};
event_loop
.run_app(&mut app)
.expect("winit event loop run_app");
}
/// Tiny argv parser. No `clap` because the surface is genuinely two
/// shapes; full CLI parsing arrives when there's more to parse. The
/// `for` ranges over a small set: at most one `--attach <socket>` or
/// `--help` arrives, plus any stray unrecognized flag.
fn parse_args(args: Vec<String>) -> Mode {
let mut iter = args.into_iter();
let Some(first) = iter.next() else {
return Mode::HelloWorld;
};
match first.as_str() {
"--attach" => {
let socket = iter.next().unwrap_or_else(|| {
eprintln!("pmacs-gpu: --attach requires a socket path");
std::process::exit(2);
});
Mode::Attach {
socket: PathBuf::from(socket),
}
}
"--help" | "-h" => {
eprintln!(
"pmacs-gpu — GPU/GUI frontend for pmacs\n\nUSAGE:\n pmacs-gpu \
hello-world (renders \"hello, pmacs\")\n pmacs-gpu --attach <socket> \
connect to a daemon's Unix socket and render its rope\n"
);
std::process::exit(0);
}
other => {
eprintln!("pmacs-gpu: unrecognized argument: {other}");
std::process::exit(2);
}
}
}
/// Top-level application handler. `state` is `Option` because winit
/// 0.30 builds the window in `resumed()`, not at `main()` start;
/// `attach_client` is held so the write half of the Unix stream
/// stays alive for as long as the window does.
struct App {
mode: Mode,
/// The event-loop proxy is taken in `resumed()` and handed to the
/// reader thread. `Option` only because it can't be cloned out of
/// a non-Option in a borrow.
proxy: Option<winit::event_loop::EventLoopProxy<AppEvent>>,
state: Option<State>,
/// Held both for stream lifetime and for the main loop's
/// `send_viewport` / `send_key` write-back path.
attach_client: Option<AttachClient>,
/// Latest modifier state from winit (`ModifiersChanged`). winit
/// delivers modifiers separately from key presses, so we track the
/// current set and apply it when a key is sent (session B1).
modifiers: winit::keyboard::ModifiersState,
}
type LoroTextDeltaBatches = Arc<Mutex<Vec<Vec<loro::TextDelta>>>>;
/// All resources owned by one running pmacs-gpu instance.
#[allow(
clippy::struct_excessive_bools,
reason = "independent render/input state flags, not a config bitset"
)]
struct State {
// `None` in the headless render-test path (F-014): a windowless State
// that renders to an offscreen texture instead of a surface.
window: Option<Arc<Window>>,
device: wgpu::Device,
queue: wgpu::Queue,
surface: Option<wgpu::Surface<'static>>,
config: wgpu::SurfaceConfiguration,
font_system: FontSystem,
swash_cache: SwashCache,
viewport: Viewport,
atlas: TextAtlas,
text_renderer: TextRenderer,
quad_renderer: QuadRenderer,
squiggle_renderer: SquiggleRenderer,
buffer: Buffer,
/// What the buffer is currently shaped to. Held so we can detect
/// no-op updates and skip the re-shape.
current_text: String,
/// Buffer-absolute byte offset for each source line in
/// `current_text`. Updated with text changes and reused by
/// reshape/scroll logic so those paths do not rescan the whole
/// file on every semantic frame.
current_line_starts: Vec<u64>,
/// 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<u64>,
/// Code-shape data used to give the minimap horizontal structure
/// even though `FileStyleSummary` carries only one dominant style
/// per line. Refreshed when a new summary lands, keeping this
/// cache in cadence with the debounced minimap data rather than
/// rebuilding it for every typed byte.
current_line_shapes: Vec<MinimapLineShape>,
/// Local CRDT replica seeded by `BufferSnapshot`. `None` in
/// hello-world mode or before the first snapshot arrives in
/// attach mode.
loro_doc: Option<loro::LoroDoc>,
/// 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<LoroTextDeltaBatches>,
/// 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<loro::Subscription>,
/// 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<BufferId>,
/// 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<StyleSpan>,
/// 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<Decoration>,
/// 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<InlineAdornment>,
/// 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<FileStyleSummaryState>,
/// 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<FrontendId, PeerPresence>,
/// 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<OwnCursor>,
/// 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<FrontendId>,
/// 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<arboard::Clipboard>,
/// 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<OwnCursor>,
/// 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<std::time::Instant>,
/// 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<ProjectedRun>,
/// Line-start byte offsets of the *projected* text (cosmic-text
/// reports hits as line index + byte-within-line).
projected_line_starts: Vec<u64>,
/// 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<u64>,
/// `(when, byte, chain_count)` of the last primary Down, for
/// frontend-side multi-click detection (same-hit within the
/// interval): count 1 = single, 2 = the double already fired,
/// so the next same-hit press is a triple (Q#M4).
last_pointer_down: Option<(std::time::Instant, u64, u8)>,
/// A press began inside the minimap band (Q#M6): subsequent
/// `CursorMoved` scrubs the viewport instead of dragging a
/// selection, until release. Never sends `Pointer` events —
/// the viewport is frontend-owned.
minimap_scrub_active: bool,
/// Q#M7 — `Some(±1)` while a drag sits in the top/bottom edge
/// band; `about_to_wait` ticks the viewport one line toward the
/// pointer per [`EDGE_SCROLL_TICK`] and re-runs the drag
/// hit-test (the mouse may be stationary — `CursorMoved` alone
/// would stall the selection).
edge_scroll_dir: Option<i64>,
/// When the last edge-scroll tick fired.
edge_scroll_last: Option<std::time::Instant>,
/// 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<std::time::Instant>,
/// 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<Vec<RichChunk>>,
/// Absolute source-line index of `buffer.lines[0]`.
shaped_top: usize,
bg_vertex_buffer: ReusableVertexBuffer,
squiggle_vertex_buffer: ReusableVertexBuffer,
caret_vertex_buffer: ReusableVertexBuffer,
minimap_vertex_buffer: ReusableVertexBuffer,
/// Q#S2 — the status band's one-line text. Shaped only when the
/// composed status string changes; rendered as a second
/// `TextArea` in the same prepare pass as the main buffer.
status_buffer: Buffer,
/// The string `status_buffer` currently holds, for change
/// detection.
status_text: String,
/// Q#S2 — the band's left side (buffer name + modified dot),
/// its own buffer so it left-aligns independently of the
/// right-aligned readout.
status_left_buffer: Buffer,
/// Change-detection twin of `status_text` for the left side.
status_left_text: String,
/// Q#S1 — the wire-authoritative status facts (protocol v8).
status_facts: Option<StatusFactsLocal>,
/// 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: <query> (n/m)` in place of
/// the buffer name; the matches highlight via `SearchMatch`
/// decorations.
search_prompt: Option<SearchPromptLocal>,
/// 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<MinibufferLocal>,
/// 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<MenuLocal>,
/// Pixel of the most recent right-click, remembered so the
/// `MenuPrompt` that follows can anchor the popup there.
menu_anchor_px: (f64, f64),
/// Shaped label text for the open menu (Q#CM1), one line per row.
menu_buffer: Buffer,
/// Dedicated text renderer for the menu, so its glyphs draw in a
/// layer *over* the buffer text + caret (a popup), not interleaved
/// with them in the main text pass.
menu_text_renderer: TextRenderer,
/// Popup background / highlight / separator quads (Q#CM1).
menu_bg_vertex_buffer: ReusableVertexBuffer,
/// Shaped candidate text for the minibuffer dropdown (Q#MB1), one
/// line per candidate.
mb_buffer: Buffer,
/// Dedicated text renderer for the minibuffer dropdown (its own
/// layer over the buffer, like the menu's).
mb_text_renderer: TextRenderer,
/// Minibuffer dropdown background + selection quads (Q#MB1).
mb_bg_vertex_buffer: ReusableVertexBuffer,
/// Arc 1a Q#C5 — the live in-buffer completion popup (protocol
/// v15), or `None` when closed.
completion: Option<CompletionLocal>,
/// 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<u8>)>,
/// 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<String, CellStyle>,
}
/// 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
_ => '.',
}
}
/// 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<String>,
}
/// 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<u32>,
total: u32,
regex: bool,
invalid: bool,
}
/// The live minibuffer (Q#MB1, protocol v12), mirrored from a
/// `MinibufferPrompt` whose `prompt` was `Some`. The prompt+input draw
/// in the bottom band with a caret; `candidates` (a windowed slice) feed
/// the dropdown.
#[derive(Clone, Debug, PartialEq)]
struct MinibufferLocal {
prompt: String,
input: String,
cursor: u32,
candidates: Vec<String>,
selected: Option<u32>,
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<CompletionPopupRow>,
/// Highlighted row within `rows`.
selected: Option<u32>,
/// 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<MenuPromptRow>,
active: Option<u32>,
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<SelectionSnapshot>,
}
struct QuadRenderer {
pipeline: wgpu::RenderPipeline,
}
#[derive(Clone, Debug)]
struct FileStyleSummaryState {
generation: u64,
lines: Vec<CellStyle>,
}
impl App {
/// Ship a Pointer event if the daemon speaks protocol v5+ — the
/// Q#M1 frontend-side gate (an older instance cannot decode the
/// variant and would drop the connection).
fn send_pointer(&self, buffer_id: BufferId, byte: u64, kind: PointerKind, mods: Modifiers) {
let Some(client) = self.attach_client.as_ref() else {
return;
};
if client.server_protocol_version() < 5 {
return;
}
// TripleDown is a v7 variant; a pre-v7 instance would
// hard-error decoding it. Downgrade to a plain Down — the
// exact behavior the third click had before v7 (the chain
// restarting).
let kind = if kind == PointerKind::TripleDown && client.server_protocol_version() < 7 {
PointerKind::Down
} else {
kind
};
// Context (right-click, Q#CM1) is a v11 variant; a pre-v11
// instance can't open a menu, so drop the gesture rather than
// sending an undecodable variant.
if kind == PointerKind::Context && client.server_protocol_version() < 11 {
return;
}
if let Err(e) = client.send_pointer(buffer_id, byte, kind, mods) {
eprintln!("pmacs-gpu: send_pointer failed: {e}");
}
}
/// Ship a [`pmacs_protocol::FrontendEvent::MenuPointer`] if the
/// daemon speaks v11+ (Q#CM1). Navigates the open menu the daemon
/// owns; pixels stay local, only the resolved row index crosses.
fn send_menu_pointer(&self, index: Option<u32>, invoke: bool) {
let Some(client) = self.attach_client.as_ref() else {
return;
};
if client.server_protocol_version() < 11 {
return;
}
if let Err(e) = client.send_menu_pointer(index, invoke) {
eprintln!("pmacs-gpu: send_menu_pointer failed: {e}");
}
}
}
impl ApplicationHandler<AppEvent> for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let initial_text = match &self.mode {
Mode::HelloWorld => HELLO_TEXT,
Mode::Attach { .. } => "(connecting...)",
};
self.state = Some(State::new(event_loop, initial_text));
// In attach mode, kick off the connection now that the event
// loop is running and a proxy is available. Failure logs and
// leaves the window showing its `(connecting...)` placeholder
// — better UX than killing the window during dev.
if let Mode::Attach { socket } = self.mode.clone() {
let proxy = self.proxy.take().expect("proxy taken twice");
match attach::connect(&socket, proxy) {
Ok(client) => {
if let Some(state) = self.state.as_mut() {
state.set_frontend_id(client.frontend_id());
}
self.attach_client = Some(client);
}
Err(e) => {
eprintln!("pmacs-gpu: attach failed: {e}");
if let Some(state) = self.state.as_mut() {
// Render a concise, actionable line in the window
// itself (F-003) — e.g. a non-CRDT daemon — rather
// than a generic "see stderr" the user won't read.
state.set_text(&e.window_status());
}
}
}
}
}
#[allow(clippy::too_many_lines)] // linear per-event dispatch; splitting hides the input flow.
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::ModifiersChanged(mods) => self.modifiers = mods.state(),
WindowEvent::KeyboardInput { event: key, .. } => {
if key.state != ElementState::Pressed {
return;
}
// While the daemon is intercepting keystrokes — an active
// incremental search (Q#SR5), or a minibuffer / pending
// prefix — every key belongs to its handler, not the
// buffer. The GUI round-trips them all and never
// optimistic-applies (that would edit the document
// mid-search).
let intercept = self
.state
.as_ref()
.is_some_and(State::daemon_intercepts_keys);
// Arc 1a Q#C6 — the completion popup is NON-modal, so it
// never flips the intercept gate (typing stays
// optimistic; the daemon's after-edit refresh re-ships
// the popup). Only the keys whose *default GPU handling
// is wrong under a popup* need this flag: Esc (below,
// else it's the local quit) and RET/TAB (the optimistic
// gate further down, else they'd insert instead of
// accept). C-n/C-p/C-g already round-trip as command
// chords, Up/Down as forwarded motion keys — the daemon's
// completion shadow handles all of them.
let completion_open = self
.state
.as_ref()
.is_some_and(State::completion_open_for_current_buffer);
// Escape cancels an active intercept (e.g. a running
// search) or dismisses the completion popup; otherwise it
// stays the local quit.
if matches!(key.logical_key, Key::Named(NamedKey::Escape)) {
if intercept || completion_open {
if let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE)
{
eprintln!("pmacs-gpu: send Escape (cancel) failed: {e}");
}
} else {
event_loop.exit();
}
return;
}
let Some((pkey, mut pmods)) = translate_key(&key.logical_key, self.modifiers)
else {
return;
};
// AltGr / international text (audit F-004). winit reports
// the text a keypress produces; when a keypress yields
// printable text *while both Ctrl and Alt* are held — the
// AltGr signature on Windows (LCtrl+RAlt) — it's text
// input, not a command chord. Strip those modifiers (keep
// Shift) so it inserts (through the plain-text path, or the
// daemon's SelfInsert while a prompt is open) instead of
// being routed to the keymap. Alt alone is left intact so
// macOS Option-as-Meta still reaches the keymap; on layouts
// where AltGr isn't Ctrl+Alt this is a no-op.
if matches!(pkey, ProtocolKey::Char(_))
&& is_layout_text(key.text.as_deref(), pmods)
{
pmods = if pmods.contains(Modifiers::SHIFT) {
Modifiers::SHIFT
} else {
Modifiers::NONE
};
}
// Ctrl-V — OS paste (Q#CM6). Read the system clipboard
// locally via arboard and ship it as a `Paste` event; the
// daemon inserts it. Handled before binding `client` so
// the `&mut self` clipboard read doesn't conflict with the
// client borrow. Skipped while intercepting (the daemon's
// active handler owns the key then). The daemon keymap's
// C-y yanks the in-app slot instead.
if !intercept && pkey == ProtocolKey::Char('v') && pmods == Modifiers::CTRL {
let bytes = self.state.as_mut().and_then(State::read_os_clipboard);
if let Some(bytes) = bytes
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_paste(bytes)
{
eprintln!("pmacs-gpu: send_paste failed: {e}");
}
return;
}
let Some(client) = self.attach_client.as_ref() else {
return;
};
// Intercept path: round-trip every key into the daemon's
// active handler (search query / step / accept / cancel).
if intercept {
if let Some(state) = self.state.as_mut() {
state.mark_cursor_stale_after_round_trip();
}
if debug_input() {
eprintln!("pmacs-gpu send_key (intercepted): {pkey:?} mods={pmods:?}");
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key (intercepted) failed: {e}");
}
return;
}
// Idle: forward any command chord (Char/Enter/Tab with
// Ctrl or Alt) to the daemon (Q#GC1). These drive the
// keymap — `C-a`, `M-f`, `C-x C-s`, isearch/clipboard/M-x,
// … — the same path the TUI forwards everything through.
// The GUI no longer withholds them (the minibuffer / prompt
// flows they open now render, Q#MB1). Once a forwarded
// chord opens a prompt or enters a prefix, `dispatch_idle`
// flips false and the intercept gate round-trips the rest —
// no optimistic local flip, so a chord that changes no
// daemon state can never wedge the gate. (Ctrl-V / OS paste
// is handled locally above and never reaches here.)
if is_command_chord(pkey, pmods) {
if let Some(state) = self.state.as_mut() {
state.mark_cursor_stale_after_round_trip();
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key (command chord) failed: {e}");
}
return;
}
// Session B2 forwards cursor motion + plain text editing
// (Char / Backspace / Enter / Delete / Tab). Command chords
// are handled above; Meta/Super-only chords fall through
// here and are withheld, leaving OS/WM shortcuts (Cmd-Q,
// Cmd-C) to the platform.
if !should_forward_key(pkey, pmods) {
return;
}
// Arc 1a Q#C6 — with the popup open, RET and TAB mean
// "accept", not "insert \n / \t": skip the optimistic
// path so they round-trip into the daemon's
// dispatch_completion_key. Everything else stays
// optimistic.
let completion_takes_key =
completion_open && matches!(pkey, ProtocolKey::Enter | ProtocolKey::Tab);
if !completion_takes_key
&& let Some(op) = self.state.as_mut().and_then(|state| {
state
.optimistic_crdt_insert(pkey, pmods)
.or_else(|| state.optimistic_crdt_delete(pkey, pmods))
})
{
if debug_input() {
eprintln!(
"pmacs-gpu send_crdt: key={pkey:?} buf={:?} bytes={}B",
op.buffer_id,
op.op.bytes.len()
);
}
if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) {
eprintln!("pmacs-gpu: send_crdt_op failed: {e}");
}
// An optimistic edit near the viewport edge can
// scroll (a wrap-inducing insert, a Backspace
// above the top); re-declare the scoped viewport
// so the producer styles the newly visible lines.
if let Some(vp) = op.viewport
&& let Err(e) =
client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: send Viewport failed: {e}");
}
return;
}
if let Some(state) = self.state.as_mut() {
if state.defer_round_trip_key_if_needed(pkey, pmods) {
if debug_input() {
eprintln!(
"pmacs-gpu defer_key: {pkey:?} mods={pmods:?} \
pending optimistic cursor"
);
}
return;
}
state.mark_cursor_stale_after_round_trip();
}
if debug_input() {
eprintln!("pmacs-gpu send_key: {pkey:?} mods={pmods:?}");
}
if let Err(e) = client.send_key(pkey, pmods) {
eprintln!("pmacs-gpu: send_key failed: {e}");
}
}
WindowEvent::Resized(size) => {
let vp = self
.state
.as_mut()
.and_then(|state| state.resize(size.width.max(1), size.height.max(1)));
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: resize send_viewport failed: {e}");
}
}
// Session M-2 — pointer input (docs/pmacs-gpu-mouse-framing.md).
WindowEvent::CursorMoved { position, .. } => {
let Some(state) = self.state.as_mut() else {
return;
};
state.pointer_pos = Some((position.x, position.y));
// Q#CM1 — while the menu is open, motion only moves the
// highlight; send a hover when the item under the pointer
// changes from the daemon's current active row.
if state.menu.is_some() {
let hit = state.menu_hit(position.x, position.y);
let active = state.menu.as_ref().and_then(|m| m.active);
if let Some((row, true)) = hit
&& active != Some(row)
{
self.send_menu_pointer(Some(row), false);
}
return;
}
if state.minimap_scrub_active {
// Scrubbing (Q#M6): the press began on the
// minimap; motion keeps jumping, even if the
// pointer wanders out of the band.
let vp = state.minimap_jump_to(position.y);
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) =
client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: minimap scrub send_viewport failed: {e}");
}
return;
}
if !state.pointer_drag_active {
return;
}
// Q#M7 — arm/disarm edge auto-scroll from the drag's
// vertical position; `about_to_wait` runs the ticks.
state.edge_scroll_dir =
edge_scroll_direction(position.y as f32, state.config.height);
// Drag coalescing (predicted finding #4): pixel-rate
// motion only ships when the hit byte changes.
let Some(byte) = state.hit_test_source_byte(position.x, position.y) else {
return;
};
if state.last_pointer_sent_byte == Some(byte) {
return;
}
state.last_pointer_sent_byte = Some(byte);
state.note_pointer_round_trip();
let buffer_id = state.current_buffer_id;
let mods = translate_mods(self.modifiers);
if let Some(buffer_id) = buffer_id {
self.send_pointer(buffer_id, byte, PointerKind::Drag, mods);
}
}
WindowEvent::MouseInput {
state: button_state,
button: winit::event::MouseButton::Left,
..
} => {
let Some(state) = self.state.as_mut() else {
return;
};
let Some((x, y)) = state.pointer_pos else {
return;
};
// Q#CM1 — while the menu is open the left button drives
// it: a press invokes the row under the pointer (or
// dismisses on a click outside); a release is swallowed.
if state.menu.is_some() {
if button_state == ElementState::Pressed {
let action = match state.menu_hit(x, y) {
Some((row, true)) => Some((Some(row), true)),
Some((_, false)) => None, // separator — ignore
None => Some((None, true)), // outside — dismiss
};
if let Some((index, invoke)) = action {
self.send_menu_pointer(index, invoke);
}
}
return;
}
let mods = translate_mods(self.modifiers);
match button_state {
ElementState::Pressed => {
if state.in_minimap_band(x, y) {
// Q#M6 — consumed before text hit-testing;
// never a Pointer event.
state.minimap_scrub_active = true;
let vp = state.minimap_jump_to(y);
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) =
client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: minimap jump send_viewport failed: {e}");
}
return;
}
let Some(byte) = state.hit_test_source_byte(x, y) else {
return;
};
let kind =
state.classify_pointer_down(byte, mods.contains(Modifiers::SHIFT));
state.pointer_drag_active = true;
state.last_pointer_sent_byte = Some(byte);
state.note_pointer_round_trip();
if let Some(buffer_id) = state.current_buffer_id {
if debug_input() {
eprintln!("pmacs-gpu pointer: {kind:?} byte={byte}");
}
self.send_pointer(buffer_id, byte, kind, mods);
}
}
ElementState::Released => {
if state.minimap_scrub_active {
state.minimap_scrub_active = false;
return;
}
if !state.pointer_drag_active {
return;
}
state.pointer_drag_active = false;
state.edge_scroll_dir = None;
state.edge_scroll_last = None;
let byte = state
.hit_test_source_byte(x, y)
.or(state.last_pointer_sent_byte);
let buffer_id = state.current_buffer_id;
if let (Some(byte), Some(buffer_id)) = (byte, buffer_id) {
self.send_pointer(buffer_id, byte, PointerKind::Up, mods);
}
}
}
}
// Q#CM1 — right-click opens the context menu at the hit byte
// (or dismisses an open one). The anchor pixel is remembered
// so the popup the daemon sends back draws at the click.
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: winit::event::MouseButton::Right,
..
} => {
let Some(state) = self.state.as_mut() else {
return;
};
let Some((x, y)) = state.pointer_pos else {
return;
};
if state.menu.is_some() {
self.send_menu_pointer(None, true);
return;
}
let Some(byte) = state.hit_test_source_byte(x, y) else {
return;
};
state.menu_anchor_px = (x, y);
let buffer_id = state.current_buffer_id;
let mods = translate_mods(self.modifiers);
if let Some(buffer_id) = buffer_id {
self.send_pointer(buffer_id, byte, PointerKind::Context, mods);
}
}
WindowEvent::MouseWheel { delta, .. } => {
let Some(state) = self.state.as_mut() else {
return;
};
// Wheel scroll is local-only: the GPU owns the
// viewport. Positive winit y = scroll up = smaller
// scroll_top.
let lines = match delta {
winit::event::MouseScrollDelta::LineDelta(_, y) => {
(-y * WHEEL_LINES_PER_TICK).round() as i64
}
winit::event::MouseScrollDelta::PixelDelta(p) => {
(-(p.y as f32) / CODE_LINE_HEIGHT).round() as i64
}
};
if lines == 0 {
return;
}
let vp = state.scroll_by_lines(lines);
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: wheel send_viewport failed: {e}");
}
}
WindowEvent::RedrawRequested => {
if let Some(state) = self.state.as_mut() {
state.render();
}
}
_ => {}
}
}
/// The deadline pump. Two timed concerns share it, both armed
/// rarely:
///
/// * Q#M7 — the edge auto-scroll tick, while a drag sits in
/// the top/bottom edge band. Each due tick scrolls one line
/// toward the pointer and re-runs the drag hit-test at the
/// *current* pointer position, so the selection keeps
/// growing while the mouse is stationary past the edge.
/// * Q#M6 (bet #2) — the post-jump styled-redraw hold: if the
/// daemon's restyle hasn't landed by the deadline, draw the
/// unstyled frame anyway (responsiveness floor).
///
/// With neither armed the loop stays in plain `Wait`.
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let Some(state) = self.state.as_mut() else {
return;
};
let now = std::time::Instant::now();
let mut next_wake: Option<std::time::Instant> = None;
// Q#M6 — held post-jump frame.
if let Some(deadline) = state.styled_redraw_deadline {
if now >= deadline {
state.styled_redraw_deadline = None;
state.request_redraw();
} else {
next_wake = Some(deadline);
}
}
// Q#M7 — edge auto-scroll.
let mut drag_resend: Option<(BufferId, u64)> = None;
if state.pointer_drag_active
&& let Some(dir) = state.edge_scroll_dir
{
let due = state
.edge_scroll_last
.is_none_or(|at| now.duration_since(at) >= EDGE_SCROLL_TICK);
if due {
state.edge_scroll_last = Some(now);
let vp = state.scroll_by_lines(dir);
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: edge-scroll send_viewport failed: {e}");
}
let state = self.state.as_mut().expect("checked above");
if let Some((x, y)) = state.pointer_pos
&& let Some(byte) = state.hit_test_source_byte(x, y)
&& state.last_pointer_sent_byte != Some(byte)
{
state.last_pointer_sent_byte = Some(byte);
state.note_pointer_round_trip();
if let Some(buffer_id) = state.current_buffer_id {
drag_resend = Some((buffer_id, byte));
}
}
}
let last = self
.state
.as_ref()
.and_then(|s| s.edge_scroll_last)
.unwrap_or(now);
let tick_wake = last + EDGE_SCROLL_TICK;
next_wake = Some(next_wake.map_or(tick_wake, |w| w.min(tick_wake)));
}
if let Some((buffer_id, byte)) = drag_resend {
let mods = translate_mods(self.modifiers);
self.send_pointer(buffer_id, byte, PointerKind::Drag, mods);
}
event_loop.set_control_flow(match next_wake {
Some(at) => winit::event_loop::ControlFlow::WaitUntil(at),
None => winit::event_loop::ControlFlow::Wait,
});
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: AppEvent) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
AppEvent::Attach(AttachEvent::Message(msg)) => {
let debug_apply = debug_apply();
let apply_start = debug_apply.then(std::time::Instant::now);
let label = debug_apply.then(|| instance_message_label(msg.as_ref()));
let follow_up = state.apply_attach_message(*msg);
if let (Some(start), Some(label)) = (apply_start, label) {
eprintln!(
"pmacs-gpu apply: {label}={}us",
std::time::Instant::now().duration_since(start).as_micros()
);
}
// If the message triggered a follow-up Viewport
// (currently: every BufferSnapshot does), emit it back
// to the daemon. The daemon's `SemanticRenderState`
// produces no styling until a viewport is declared.
if let Some(ViewportSend {
buffer_id,
visible,
generation,
}) = follow_up
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_viewport(buffer_id, visible, generation)
{
eprintln!("pmacs-gpu: send Viewport failed: {e}");
}
state.release_timed_out_floor();
let ready_keys = state.take_ready_round_trip_keys();
if let Some(client) = self.attach_client.as_ref() {
for (key, mods) in ready_keys {
if debug_input() {
eprintln!("pmacs-gpu flush_key: {key:?} mods={mods:?}");
}
if let Err(e) = client.send_key(key, mods) {
eprintln!("pmacs-gpu: flush send_key failed: {e}");
}
}
}
}
AppEvent::Attach(AttachEvent::Disconnected(reason)) => {
eprintln!("pmacs-gpu: daemon disconnected ({reason})");
state.set_text("(daemon disconnected)");
}
}
}
}
/// Follow-up event the main loop fires back to the daemon after
/// processing a message. Right now only Viewport (post-snapshot);
/// later sessions extend this enum.
#[derive(Debug, Clone, Copy)]
struct ViewportSend {
buffer_id: BufferId,
visible: ByteRange,
generation: u64,
}
#[derive(Debug)]
struct CrdtOpSend {
buffer_id: BufferId,
op: CrdtOp,
/// A scoped-viewport re-declaration when the optimistic insert
/// scrolled the view (Enter on the bottom visible line). Sent
/// after the op so the producer styles the newly visible range.
viewport: Option<ViewportSend>,
}
/// How long an unconfirmed optimistic-cursor prediction may gate
/// `CursorByte` acceptance and defer round-trip keys before the
/// escape hatch releases it. Generous against a busy daemon tick;
/// tiny against a human noticing wedged keys.
const FLOOR_CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
/// Frontend-side double-click interval (Q#M1: the daemon cannot see
/// pixels, so the frontend decides what a double-click is). Matches
/// the TUI's `DOUBLE_CLICK_MAX_DELAY`.
const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
/// Wheel lines scrolled per `MouseScrollDelta::LineDelta` unit.
const WHEEL_LINES_PER_TICK: f32 = 3.0;
/// Byte range an optimistic Backspace/Delete removes at `cursor`, or
/// `None` when it can't be predicted locally: buffer edge (the
/// daemon's behavior is a no-op there anyway), a modifier variant
/// (C-BS word-delete and friends are separate bindings), or a stale
/// mid-codepoint cursor. The range is exactly one codepoint, matching
/// `buffer.delete-backward` / `buffer.delete-forward`'s no-region
/// behavior; region deletes are excluded upstream by the selection
/// gate (they round-trip into `delete_region`).
fn optimistic_delete_range(
text: &str,
cursor: usize,
key: ProtocolKey,
mods: Modifiers,
) -> Option<(usize, usize)> {
if !mods.is_empty() {
return None;
}
if cursor > text.len() || !text.is_char_boundary(cursor) {
return None;
}
match key {
ProtocolKey::Backspace => {
let (start, _) = text[..cursor].char_indices().next_back()?;
Some((start, cursor))
}
ProtocolKey::Delete => {
let ch = text[cursor..].chars().next()?;
Some((cursor, cursor + ch.len_utf8()))
}
_ => None,
}
}
/// The literal text `key` inserts when handled optimistically, or
/// `None` for keys that must round-trip through the daemon.
///
/// `Tab` qualifies alongside printable chars because its default
/// binding (`buffer.tab`) reduces to a plain `insert_char(9)` —
/// byte-identical to a self-insert, so the local application cannot
/// diverge from what the daemon will do with the same op. `Enter`
/// does NOT: since Q#AI1 (docs/auto-indent-framing.md) RET binds
/// `edit.newline-and-indent`, whose inserted text depends on the
/// current line's indentation — and round-tripping is also what makes
/// RET rebindings (e.g. the buffer list's visit binding) reachable
/// from this frontend at all. Two caveats are the caller's job:
/// `optimistic_crdt_insert` round-trips when an own-window selection
/// is active (the daemon commands consume the region first — CUA
/// type-over — which a raw op can't), and modified variants (`C-TAB`,
/// …) return `None` here: a keymap may bind them to anything.
fn optimistic_insert_text(key: ProtocolKey, mods: Modifiers, chbuf: &mut [u8; 4]) -> Option<&str> {
if !is_plain_text_modifiers(mods) {
return None;
}
match key {
// Auto-pairing Q#AP1: the built-in pair charset always
// round-trips so the typed opener and the pairing hook's
// closer land as adjacent daemon-peer undo units, and
// dispatch-path CUA type-over / skip-over-close apply. An
// optimistic pair char would put the opener on this
// frontend's peer with the closer on the daemon's — uncleanly
// undoable from either side.
ProtocolKey::Char(ch) if !ch.is_control() && !is_builtin_pair_char(ch) => {
Some(ch.encode_utf8(chbuf))
}
ProtocolKey::Tab if mods.is_empty() => Some("\t"),
_ => None,
}
}
/// A vertex buffer reused across frames: rewritten in place while the
/// data fits, reallocated (with slack) when it grows. `render()`
/// previously allocated fresh wgpu buffers for the background / caret
/// / minimap quads on every frame.
/// `(summary generation, surface width, surface height, scroll_top)`
/// — everything the minimap quads depend on.
type MinimapCacheKey = (u64, u32, u32, usize);
struct ReusableVertexBuffer {
buffer: Option<wgpu::Buffer>,
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). 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.
#[cfg(test)]
fn new_headless(width: u32, height: u32, initial_text: &str) -> Option<Self> {
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,
))
}
/// 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<Arc<Window>>,
surface: Option<wgpu::Surface<'static>>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
initial_text: &str,
) -> Self {
// Pipelines, atlas, and any offscreen texture must all share the
// render-target format; `config.format` is the single source.
let format = config.format;
let mut font_system = FontSystem::new();
font_system.db_mut().load_font_data(JETBRAINS_MONO.to_vec());
let swash_cache = SwashCache::new();
let cache = Cache::new(&device);
let mut viewport = Viewport::new(&device, &cache);
viewport.update(
&queue,
Resolution {
width: config.width,
height: config.height,
},
);
let mut atlas = TextAtlas::new(&device, &queue, &cache, format);
let text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
// Q#CM1 — a second renderer so the menu draws as a top layer.
let menu_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
// Q#MB1 — a third renderer for the minibuffer dropdown layer.
let mb_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
// Arc 1a Q#C5 — a renderer for the completion dropdown layer.
let completion_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
// UX gutter — a renderer for the line-number layer.
let gutter_text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
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(16.0, 22.0));
buffer.set_size(
&mut font_system,
Some(config.width as f32),
Some(config.height as f32),
);
let mut status_buffer = Buffer::new(
&mut font_system,
Metrics::new(STATUS_FONT_SIZE, STATUS_LINE_HEIGHT),
);
status_buffer.set_size(
&mut font_system,
Some(config.width as f32),
Some(STATUS_BAND_HEIGHT),
);
let mut status_left_buffer = Buffer::new(
&mut font_system,
Metrics::new(STATUS_FONT_SIZE, STATUS_LINE_HEIGHT),
);
status_left_buffer.set_size(
&mut font_system,
Some(config.width as f32),
Some(STATUS_BAND_HEIGHT),
);
let mut menu_buffer = Buffer::new(
&mut font_system,
Metrics::new(MENU_FONT_SIZE, MENU_LINE_HEIGHT),
);
menu_buffer.set_size(
&mut font_system,
Some(MENU_MAX_WIDTH),
Some(config.height as f32),
);
let mut mb_buffer = Buffer::new(
&mut font_system,
Metrics::new(MB_DROP_FONT_SIZE, MB_DROP_LINE_HEIGHT),
);
mb_buffer.set_size(
&mut font_system,
Some(MB_DROP_MAX_WIDTH),
Some(config.height as f32),
);
// Completion dropdown buffer (Arc 1a): the minibuffer
// dropdown's metrics, its own layer.
let mut completion_buffer = Buffer::new(
&mut font_system,
Metrics::new(MB_DROP_FONT_SIZE, MB_DROP_LINE_HEIGHT),
);
completion_buffer.set_size(
&mut font_system,
Some(MB_DROP_MAX_WIDTH),
Some(config.height as f32),
);
// 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(CODE_FONT_SIZE, CODE_LINE_HEIGHT),
);
gutter_buffer.set_size(
&mut font_system,
Some(config.width as f32),
Some(config.height as f32),
);
buffer.set_text(
&mut font_system,
initial_text,
&Attrs::new().family(Family::Name("JetBrains Mono")),
Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut font_system, false);
let (current_line_starts, current_line_char_starts) = line_offset_tables(initial_text);
Self {
window,
device,
queue,
surface,
config,
font_system,
swash_cache,
viewport,
atlas,
text_renderer,
quad_renderer,
squiggle_renderer,
buffer,
current_text: initial_text.to_owned(),
current_line_starts,
current_line_char_starts,
current_line_shapes: minimap_line_shapes(initial_text),
loro_doc: None,
loro_text_delta_batches: None,
loro_text_subscription: None,
current_buffer_id: None,
current_spans: Vec::new(),
current_decorations: Vec::new(),
current_adornments: Vec::new(),
current_summary: None,
peer_presences: HashMap::new(),
own_cursor: None,
scroll_top: 0,
view_range: (0, 0),
last_viewport_sent: None,
local_frontend_id: None,
dispatch_idle: false,
clipboard: None,
cursor_fresh: false,
optimistic_cursor_floor: None,
deferred_round_trip_keys: Vec::new(),
optimistic_floor_set_at: None,
unconfirmed_edits: Vec::new(),
current_hit_runs: Vec::new(),
projected_line_starts: vec![0],
pointer_pos: None,
pointer_drag_active: false,
last_pointer_sent_byte: None,
last_pointer_down: None,
minimap_scrub_active: false,
edge_scroll_dir: None,
edge_scroll_last: None,
styled_redraw_deadline: None,
hit_map_dirty: false,
line_chunk_cache: Vec::new(),
shaped_top: 0,
bg_vertex_buffer: ReusableVertexBuffer::new(),
squiggle_vertex_buffer: ReusableVertexBuffer::new(),
caret_vertex_buffer: ReusableVertexBuffer::new(),
minimap_vertex_buffer: ReusableVertexBuffer::new(),
status_buffer,
status_text: String::new(),
status_left_buffer,
status_left_text: String::new(),
status_facts: None,
search_prompt: None,
minibuffer: None,
menu: None,
menu_anchor_px: (0.0, 0.0),
menu_buffer,
menu_text_renderer,
menu_bg_vertex_buffer: ReusableVertexBuffer::new(),
mb_buffer,
mb_text_renderer,
mb_bg_vertex_buffer: ReusableVertexBuffer::new(),
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,
}
}
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<CrdtOpSend> {
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<CrdtOpSend> {
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<loro::TextDelta>],
predicted: OwnCursor,
peer_id: u64,
bytes: Vec<u8>,
) -> 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.
let viewport = if self.scroll_to_cursor() {
self.rebuild_lines_reusing_scroll();
self.viewport_send_if_changed(predicted.buffer_id)
} else {
None
};
CrdtOpSend {
buffer_id: predicted.buffer_id,
op: CrdtOp { peer_id, bytes },
viewport,
}
}
fn mark_cursor_stale_after_round_trip(&mut self) {
self.cursor_fresh = false;
}
fn apply_loro_text_delta_batches(
&mut self,
delta_batches: &[Vec<loro::TextDelta>],
) -> Result<Vec<TextProjectionEdit>, &'static str> {
let line_count_before = self.current_line_starts.len();
let edits = apply_loro_text_delta_batches(
&mut self.current_text,
&mut self.current_line_starts,
&mut self.current_line_char_starts,
delta_batches,
)?;
if edits.is_empty() {
return Ok(edits);
}
self.translate_cached_anchors(&edits);
// Q#R1 — the keystroke case (one edit, no line-structure
// change) re-shapes only the affected BufferLine; everything
// else falls back to the full slice reshape.
let single_line_edit = edits.len() == 1
&& self.current_line_starts.len() == line_count_before
&& !self.current_text
[edits[0].start as usize..(edits[0].start + edits[0].inserted_len) as usize]
.contains('\n');
if !(single_line_edit && self.try_reshape_line(edits[0])) {
self.reshape();
}
Ok(edits)
}
fn translate_cached_anchors(&mut self, edits: &[TextProjectionEdit]) {
for edit in edits {
translate_style_spans(&mut self.current_spans, *edit);
translate_decorations(&mut self.current_decorations, *edit);
translate_inline_adornments(&mut self.current_adornments, *edit);
}
}
/// Drop journal entries already reflected in a producer frame
/// stamped `generation` — see the `unconfirmed_edits` field docs.
fn prune_unconfirmed_edits(&mut self, generation: u64) {
self.unconfirmed_edits
.retain(|(scalar, _)| *scalar > generation);
}
fn optimistic_floor_timed_out(&self) -> bool {
self.optimistic_floor_set_at
.is_some_and(|armed| armed.elapsed() >= FLOOR_CONFIRM_TIMEOUT)
}
/// Escape hatch: release a floor the daemon never confirmed so
/// deferred round-trip keys can't wedge forever. Dropping
/// `cursor_fresh` falls the GPU back to round-trip mode until the
/// next `CursorByte` resynchronizes the cursor.
fn release_timed_out_floor(&mut self) {
if self.optimistic_cursor_floor.is_some() && self.optimistic_floor_timed_out() {
eprintln!(
"pmacs-gpu: optimistic cursor unconfirmed after {FLOOR_CONFIRM_TIMEOUT:?}; \
falling back to round-trip input"
);
self.optimistic_cursor_floor = None;
self.optimistic_floor_set_at = None;
self.cursor_fresh = false;
}
}
fn defer_round_trip_key_if_needed(&mut self, key: ProtocolKey, mods: Modifiers) -> bool {
if self.optimistic_cursor_floor.is_none() && self.deferred_round_trip_keys.is_empty() {
return false;
}
self.cursor_fresh = false;
self.deferred_round_trip_keys.push((key, mods));
true
}
fn take_ready_round_trip_keys(&mut self) -> Vec<(ProtocolKey, Modifiers)> {
if self.optimistic_cursor_floor.is_some() || self.deferred_round_trip_keys.is_empty() {
return Vec::new();
}
self.cursor_fresh = false;
std::mem::take(&mut self.deferred_round_trip_keys)
}
/// Replace the rendered text with `text` and request a redraw.
/// Returns `false` when `text` is byte-identical to the current
/// rendering (avoids the re-shape cost when an unchanged buffer
/// ticks).
///
/// Replaces the rope text and routes through `reshape` so the
/// rich-text rendering uses the current spans, decorations, and
/// inline adornments. When called from the `CrdtOp` path (text
/// shifted under existing source anchors) those anchors are
/// momentarily stale relative to the new byte positions —
/// `reshape` clamps via `range.end.min(text_len)` so rendering is
/// safe, but visual styling may be off until the daemon's next
/// semantic frame catches up. A real artifact; classified as a
/// known Phase A limitation rather than a bug.
fn set_text(&mut self, text: &str) -> bool {
if self.current_text == text {
return false;
}
self.current_text.clear();
self.current_text.push_str(text);
let (line_starts, line_char_starts) = line_offset_tables(text);
self.current_line_starts = line_starts;
self.current_line_char_starts = line_char_starts;
self.reshape();
true
}
/// Apply one `InstanceMessage`; return a follow-up
/// `ViewportSend` if the message requires the main loop to fire
/// one back at the daemon.
///
/// Session 4 introduced four variants; session 5 adds
/// `Decorations`:
/// - `BufferSnapshot` — bootstrap a fresh `LoroDoc`, extract text,
/// request the daemon scope styling to the new buffer (return a
/// Viewport send-back).
/// - `CrdtOp` — apply incremental updates to the doc; text
/// patched from Loro's diff event.
/// - `StyleSpans` — replace or merge per the M11.4 dirty-segment
/// rule; reshape the rich-text rendering.
/// - `Decorations` — same M11.4 shape as `StyleSpans` but for the
/// `DecorationKind` set (diagnostics, selection, current line,
/// search match). Session 5 renders diagnostic kinds as fg color
/// overrides; background-kind decorations are accumulated but
/// not painted (see session 5's deferred quad-pipeline finding).
/// - `InlineAdornments` — replace the scoped virtual-text set and
/// reshape the display projection. Session 6 consumes `AtOffset`
/// text adornments (LSP inlay hints); other placements/content
/// remain explicitly deferred.
/// - `FileStyleSummary` — replace the whole-file minimap summary.
/// Session 7 renders it as a right-side per-line style overview
/// plus a visible-window affordance.
/// - `Goodbye` — surfaced via the reader thread's clean-EOF path,
/// not handled here.
///
/// The grid variants (`CellDelta`, `Cursor`, `CursorByte`) are
/// ignored — pmacs-gpu lays out locally and tracks the cursor via
/// `PresenceUpdate` (session 9.3). Remaining semantic variants land
/// in subsequent Phase A sessions.
/// Lazily-created OS clipboard handle (Q#CM6). Returns `None` if the
/// platform clipboard can't be opened, so callers degrade to no-ops.
fn os_clipboard(&mut self) -> Option<&mut arboard::Clipboard> {
if self.clipboard.is_none() {
match arboard::Clipboard::new() {
Ok(c) => self.clipboard = Some(c),
Err(e) => {
eprintln!("pmacs-gpu: OS clipboard unavailable: {e}");
return None;
}
}
}
self.clipboard.as_mut()
}
/// Read the OS clipboard as bytes (for Ctrl-V → `Paste`). `None` on
/// any failure (empty / non-text / unavailable).
fn read_os_clipboard(&mut self) -> Option<Vec<u8>> {
match self.os_clipboard()?.get_text() {
Ok(s) => Some(s.into_bytes()),
Err(e) => {
eprintln!("pmacs-gpu: clipboard read failed: {e}");
None
}
}
}
/// Write bytes to the OS clipboard (for an inbound
/// `Signal::Clipboard` after a daemon copy/cut). Lossy UTF-8; the
/// daemon only ever sends valid document text.
fn write_os_clipboard(&mut self, bytes: &[u8]) {
let text = String::from_utf8_lossy(bytes).into_owned();
if let Some(c) = self.os_clipboard()
&& let Err(e) = c.set_text(text)
{
eprintln!("pmacs-gpu: clipboard write failed: {e}");
}
}
#[allow(clippy::too_many_lines)] // per-variant match dispatcher; one arm per InstanceMessage.
fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option<ViewportSend> {
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;
self.cursor_fresh = false;
self.optimistic_cursor_floor = None;
self.optimistic_floor_set_at = None;
self.deferred_round_trip_keys.clear();
self.unconfirmed_edits.clear();
// New buffer ⇒ back to the top, and force a viewport
// re-declaration for the new buffer's scoped range.
self.scroll_top = 0;
self.last_viewport_sent = None;
if !self.set_text(&text) {
self.reshape();
}
self.viewport_send_if_changed(buffer_id)
}
InstanceMessage::CrdtOp { buffer_id, op } => {
if self.current_buffer_id != Some(buffer_id) {
// Edit op for a different buffer than we currently
// render. Ignore for now (multi-buffer is a future
// session); when buffer-switching lands we'll
// index ops by buffer.
return None;
}
let Some(doc) = self.loro_doc.as_ref() else {
// Mid-attach race: ops before snapshot. The
// snapshot will have the ops baked in.
return None;
};
let delta_batches = self.loro_text_delta_batches.clone();
if let Some(delta_batches) = delta_batches.as_ref() {
clear_loro_text_delta_batches(delta_batches);
}
let import_status = match doc.import(&op.bytes) {
Ok(status) => status,
Err(e) => {
eprintln!("pmacs-gpu: CrdtOp import failed: {e:?}");
return None;
}
};
// NOTE: `current_spans` / `current_decorations` index
// into the *pre-edit* byte positions. The producer's
// next render frame (in pmacs core, post-T M11.7
// generation-transition fix) ships `full=true`
// styling for buffers whose generation advanced, so
// the next message replaces the stale items
// wholesale via `replace_style_spans` /
// `replace_decorations`. The single-frame gap
// between CrdtOp arrival and that next frame paints
// styling at stale byte positions — the session-4
// documented "one-frame stale" artifact. A previous
// attempt to fix it by clearing both vectors here
// (`49785c4`) was reverted because the producer's
// *incremental* updates ship dirty-range spans only,
// and an emptied cache loses the non-dirty viewport
// styling entirely.
//
// InlineAdornments use whole-set suppression rather
// than dirty segments, so the same ownership rule
// applies here: keep the last set until the producer
// sends a replacement. Session 8 closed the stale
// inlay case producer-side: `didChange` marks the
// inlay store stale, and the producer sends one empty
// replacement to clear cached virtual text until a
// fresh `textDocument/inlayHint` response arrives.
let delta_batches = delta_batches
.as_ref()
.map(drain_loro_text_delta_batches)
.unwrap_or_default();
if delta_batches.is_empty() {
if !import_status.success.is_empty() {
let text = self
.loro_doc
.as_ref()
.map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string());
if let Some(text) = text {
self.set_text(&text);
}
self.unconfirmed_edits.clear();
}
} else {
match self.apply_loro_text_delta_batches(&delta_batches) {
Ok(edits) => {
// A daemon-originated edit shifts the text
// under any still-unconfirmed optimistic
// edits. Rebase the journal's anchors so
// frames that include this edit (but not
// ours) translate correctly. Entries are
// inserts (start == old_end) or
// single-codepoint deletes; both rebase by
// position translation, clamped so a range
// can't invert.
for incoming in &edits {
for (_, pending) in &mut self.unconfirmed_edits {
pending.start =
translate_byte_position(pending.start, *incoming);
pending.old_end =
translate_byte_position(pending.old_end, *incoming)
.max(pending.start);
}
}
}
Err(reason) => {
eprintln!(
"pmacs-gpu: incremental CRDT text update failed ({reason}); \
falling back to full materialization"
);
let text = self
.loro_doc
.as_ref()
.map(|doc| doc.get_text(LORO_TEXT_CONTAINER).to_string());
if let Some(text) = text {
self.set_text(&text);
}
self.unconfirmed_edits.clear();
}
}
}
// Local typing usually shifts only the viewport's end
// byte while the top visible source line stays fixed.
// The declared range includes overscan, and the daemon's
// generation bump already forces a full style resync, so
// re-declaring on every byte is mostly write amplification.
// Re-declare here only if the viewport origin moved (for
// example because an edit before `scroll_top` shifted the
// top line); scroll/resize/snapshot still send exact ranges.
self.viewport_send_if_origin_changed(buffer_id)
}
InstanceMessage::StyleSpans {
buffer_id,
generation,
full,
segments,
} => {
if self.current_buffer_id != Some(buffer_id) {
return None;
}
// The producer computed this frame against the daemon
// text at `generation` (its CRDT version scalar). Any
// optimistic local inserts the daemon hadn't integrated
// yet shift the frame's byte ranges; translate them so
// the repaint doesn't flash every color after the
// cursor a few bytes left of its glyphs for one frame
// (the typing shimmer).
self.prune_unconfirmed_edits(generation);
let segments = translate_style_segments(segments, &self.unconfirmed_edits);
if full {
self.replace_style_spans(segments);
} else {
self.merge_style_spans(segments);
}
// Re-shape only lines whose styling actually changed
// — a parse-settle frame after a burst usually
// recolors a line or two, and a scroll-triggered
// resync only the newly exposed ones.
self.refresh_changed_lines();
None
}
InstanceMessage::Decorations {
buffer_id,
generation,
full,
segments,
} => {
if self.current_buffer_id != Some(buffer_id) {
return None;
}
// Same staleness translation as the StyleSpans arm.
self.prune_unconfirmed_edits(generation);
let segments = translate_decoration_segments(segments, &self.unconfirmed_edits);
if full {
self.replace_decorations(segments);
} else {
self.merge_decorations(segments);
}
// Every decoration kind is now a quad (backgrounds
// for Selection/CurrentLine, underline bars for the
// diagnostics — the fg-recolor path retired with T
// M4.6 parity), and quads rebuild cheaply per frame
// in `render()`. No decoration change needs a
// reshape, so none triggers one — diagnostic
// publishes no longer pay set_rich_text +
// shape_until_scroll.
self.request_redraw();
None
}
InstanceMessage::InlineAdornments { buffer_id, items } => {
if self.current_buffer_id != Some(buffer_id) {
return None;
}
self.current_adornments = items;
self.current_adornments.sort_by_key(|a| a.at);
self.refresh_changed_lines();
None
}
InstanceMessage::FileStyleSummary {
buffer_id,
generation,
lines,
} => {
self.apply_file_style_summary(buffer_id, generation, lines);
None
}
// Q#S1 (protocol v8; `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 {
self.line_numbers = mode;
self.request_redraw();
}
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_text.clear();
self.status_left_text.clear();
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: <query> (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.
if moved && self.scroll_to_cursor() {
// Pure scroll: retained lines keep their shape
// caches; only newly exposed lines shape.
self.rebuild_lines_reusing_scroll();
if let Some(vp) = self.viewport_send_if_changed(buffer_id) {
return Some(vp);
}
}
self.request_redraw();
None
}
InstanceMessage::DispatchIdle { idle } => {
self.dispatch_idle = idle;
None
}
// Q#CM6 — a daemon copy/cut published the region; write it to
// the OS clipboard via arboard so other apps can paste it.
InstanceMessage::Signal(InstanceSignal::Clipboard(bytes)) => {
self.write_os_clipboard(&bytes);
None
}
// Q#CM1 — the context menu's rows + highlight. Empty rows
// close it; otherwise anchor the popup at the remembered
// right-click pixel.
InstanceMessage::MenuPrompt { rows, active, .. } => {
self.menu = if rows.is_empty() {
None
} else {
Some(MenuLocal {
rows,
active,
anchor_px: self.menu_anchor_px,
})
};
self.request_redraw();
None
}
// Q#MB1 — the minibuffer prompt/input/candidates. `prompt:
// None` closes it.
InstanceMessage::MinibufferPrompt {
prompt,
input,
cursor,
candidates,
selected,
total,
} => {
self.minibuffer = prompt.map(|prompt| MinibufferLocal {
prompt,
input,
cursor,
candidates,
selected,
total,
});
self.request_redraw();
None
}
// Arc 1a Q#C5/Q#C6 — the in-buffer completion dropdown.
// A close (`anchor: None`) always applies — the daemon may
// ship it carrying a buffer this window just switched away
// from, and dropping it would wedge a stale popup. An OPEN
// for a buffer this window isn't showing is dropped (the
// CrdtOp rule).
InstanceMessage::CompletionPopup {
buffer_id,
anchor,
prefix_len,
rows,
selected,
total,
} => {
let Some(anchor) = anchor else {
self.completion = None;
self.request_redraw();
return None;
};
if self.current_buffer_id != Some(buffer_id) {
return None;
}
self.completion = Some(CompletionLocal {
buffer_id,
anchor,
prefix_len,
rows,
selected,
total,
});
self.request_redraw();
None
}
_ => None,
}
}
/// 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<ViewportSend> {
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<ViewportSend> {
let Some((last_start, last_end)) = self.last_viewport_sent else {
return self.viewport_send_if_changed(buffer_id);
};
if last_start != self.view_range.0 {
return self.viewport_send_if_changed(buffer_id);
}
// End drift: typing grows the slice end while the declared
// end stays put, and the daemon clips styling to the declared
// range. Long unbroken typing would eat through the bottom
// overscan and the deepest lines would lose styling — once
// the drift exceeds half the overscan (in lines), re-declare.
let starts = &self.current_line_starts;
let declared_line = starts.partition_point(|&s| s <= last_end);
let current_line = starts.partition_point(|&s| s <= self.view_range.1);
if current_line.abs_diff(declared_line) * 2 > SCROLL_OVERSCAN {
return self.viewport_send_if_changed(buffer_id);
}
None
}
/// Adjust `scroll_top` so the own cursor's source line is within the
/// visible window (Q#S2). Returns whether `scroll_top` changed (in
/// which case the caller re-shapes + re-declares the viewport).
fn scroll_to_cursor(&mut self) -> bool {
let Some(own) = self.own_cursor else {
return false;
};
if self.current_buffer_id != Some(own.buffer_id) {
return false;
}
let line_starts = &self.current_line_starts;
let cursor = own.byte.min(self.current_text.len() as u64);
// Cursor's source line = largest i with line_starts[i] <= cursor.
let cursor_line = line_starts
.partition_point(|&s| s <= cursor)
.saturating_sub(1);
let visible = estimated_visible_lines(self.config.height).max(1);
let old = self.scroll_top;
if cursor_line < self.scroll_top {
self.scroll_top = cursor_line;
} else if cursor_line >= self.scroll_top + visible {
self.scroll_top = cursor_line + 1 - visible;
}
self.scroll_top != old
}
/// Monospace glyph advance in px, read from the currently-shaped code
/// buffer (every glyph shares it in a monospace font), with a fallback
/// when the buffer has no glyphs yet. Used to size the line-number
/// gutter (UX gutter arc).
fn mono_advance(&self) -> f32 {
self.buffer
.layout_runs()
.flat_map(|run| run.glyphs.iter())
.next()
.map_or(GUTTER_MONO_ADVANCE_FALLBACK, |g| g.w)
}
/// Width in px the line-number gutter reserves on the left, or 0 when
/// disabled (UX gutter arc, Q#UX3): `digits * advance + gap`. Mirrors
/// the TUI's `Window::gutter_width`; the unit here is pixels.
fn gutter_width_px(&self) -> f32 {
if !self.line_numbers.is_on() {
return 0.0;
}
let lines = self.current_line_starts.len().max(1);
let want = decimal_digits(lines) as f32 * self.mono_advance() + GUTTER_GAP_PX;
// Fit guard (mirrors the TUI's too-narrow disable): never reserve so
// much gutter that the text area collapses. `text_bounds_right` is
// the text clip edge (minimap-aware); if the gutter would leave less
// than `MIN_TEXT_WIDTH_PX` past `TEXT_LEFT`, drop it this frame
// rather than shift `text_left` to or past the clip and render into
// a degenerate `left >= right` rectangle.
let avail = self.text_bounds_right() as f32 - TEXT_LEFT;
if want + MIN_TEXT_WIDTH_PX > avail {
0.0
} else {
want
}
}
/// The code's left origin in px: `TEXT_LEFT` plus the gutter. Every
/// byte→pixel x site adds this instead of the bare `TEXT_LEFT` (Q#UX2),
/// and the pixel→byte hit-test subtracts it.
fn text_left(&self) -> f32 {
TEXT_LEFT + self.gutter_width_px()
}
/// The GPU's own cursor's 0-based buffer line, or `0` when there's no
/// own cursor in the displayed buffer (relative/hybrid then count from
/// the top — a rare transient). Derived from the whole-buffer line
/// table, so it's independent of the shaped slice.
fn cursor_line(&self) -> usize {
let byte = match self.own_cursor.as_ref() {
Some(c) if Some(c.buffer_id) == self.current_buffer_id => c.byte,
_ => 0,
};
self.current_line_starts
.partition_point(|&start| start <= byte)
.saturating_sub(1)
}
/// Reshape the gutter buffer to the right-aligned line numbers for the
/// currently-shaped code lines (UX gutter arc). One number per code
/// line starting at `shaped_top`, so the two buffers align row-for-row
/// at the same `top` and line height. 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$}");
}
self.gutter_buffer.set_text(
&mut self.font_system,
&text,
&Attrs::new().family(Family::Name("JetBrains Mono")),
Shaping::Advanced,
None,
);
self.gutter_buffer
.shape_until_scroll(&mut self.font_system, false);
}
/// Resolve a window-pixel position to an **absolute source byte**
/// (Q#M2): pixel → cosmic-text hit (shaped line + byte within
/// line) → projected byte → run map → slice byte → + `vstart`.
/// `None` when no buffer is attached or the position is outside
/// anything hit-testable.
/// Text-relative x for hit testing, classifying the gutter band first
/// (UX gutter, Q#UX6). A click left of the text origin (`raw_x < 0`,
/// i.e. inside the gutter) is not a text hit — it clamps to `0.0`, the
/// line start, rather than feeding glyphon a negative x (undefined).
/// Mirrors the TUI's saturate-to-column-0 affordance and is the stable
/// seam a future gutter marker would branch on instead of relying on
/// glyphon's negative-x edge behavior.
fn gutter_aware_rel_x(&self, x: f64) -> f32 {
let raw_x = x as f32 - self.text_left();
if self.line_numbers.is_on() && raw_x < 0.0 {
0.0
} else {
raw_x
}
}
fn hit_test_source_byte(&mut self, x: f64, y: f64) -> Option<u64> {
self.current_buffer_id?;
if self.hit_map_dirty {
// Q#R2 — a per-line reshape deferred this; rebuild from
// the same chunk source the shaped buffer was built from.
let (vstart, vend) = self.view_range;
let rich = clipped_chunks_for_range(
&self.current_text,
&self.current_spans,
&self.current_adornments,
vstart,
vend,
);
let (hit_runs, projected_line_starts) = build_hit_runs(&rich);
self.current_hit_runs = hit_runs;
self.projected_line_starts = projected_line_starts;
self.hit_map_dirty = false;
}
let rel_x = self.gutter_aware_rel_x(x);
let rel_y = y as f32 - TEXT_TOP;
let cursor = self.buffer.hit(rel_x, rel_y)?;
let line_start = *self.projected_line_starts.get(cursor.line)?;
let projected = line_start + cursor.index as u64;
let slice_byte = projected_to_source(&self.current_hit_runs, projected)?;
let (vstart, vend) = self.view_range;
Some((vstart + slice_byte).min(vend))
}
/// Wheel scroll (local-only — the GPU owns the viewport; no wire
/// event exists or is needed). Positive `delta` scrolls down.
fn scroll_by_lines(&mut self, delta: i64) -> Option<ViewportSend> {
let max_top = self.current_line_starts.len().saturating_sub(1);
let new_top = self
.scroll_top
.saturating_add_signed(delta as isize)
.min(max_top);
if new_top == self.scroll_top {
return None;
}
self.scroll_top = new_top;
self.rebuild_lines_reusing_scroll();
self.current_buffer_id
.and_then(|bid| self.viewport_send_if_changed(bid))
}
/// True when the pixel position lies inside the minimap band
/// (Q#M6). Presses here are consumed locally and never become
/// `Pointer` events.
fn in_minimap_band(&self, x: f64, y: f64) -> bool {
minimap_band_contains(x as f32, y as f32, self.config.width, self.config.height)
}
/// Popup width in pixels (Q#CM1) — widest label estimated from a
/// fixed per-char advance, padded, clamped. Used by both hit-testing
/// and the bg quad so they line up.
fn menu_width_px(menu: &MenuLocal) -> f32 {
let max_chars = menu
.rows
.iter()
.map(|r| r.label.chars().count())
.max()
.unwrap_or(0);
(max_chars as f32 * MENU_CHAR_W + 2.0 * MENU_PAD_X).clamp(MENU_MIN_WIDTH, MENU_MAX_WIDTH)
}
/// Hit-test a pixel against the open popup (Q#CM1). Returns
/// `(row_index, is_item)` when inside the popup rectangle, or `None`
/// when outside (or no menu open).
fn menu_hit(&self, x: f64, y: f64) -> Option<(u32, bool)> {
let menu = self.menu.as_ref()?;
let (ax, ay) = menu.anchor_px;
let w = f64::from(Self::menu_width_px(menu));
let h = menu.rows.len() as f64 * f64::from(MENU_ROW_HEIGHT);
if x < ax || x >= ax + w || y < ay || y >= ay + h {
return None;
}
let row =
(((y - ay) / f64::from(MENU_ROW_HEIGHT)).floor() as usize).min(menu.rows.len() - 1);
Some((row as u32, !menu.rows[row].separator))
}
/// Center the viewport on the source line the minimap pixel `y`
/// maps to — the inverse of the painter's linear line→y
/// interpolation. Reuses [`Self::scroll_by_lines`] for the
/// clamp / rebuild / viewport-send plumbing.
fn minimap_jump_to(&mut self, y: f64) -> Option<ViewportSend> {
let target =
minimap_y_to_line(y as f32, self.config.height, self.current_line_starts.len())?;
let centered = target.saturating_sub(estimated_visible_lines(self.config.height) / 2);
let delta = i64::try_from(centered).unwrap_or(i64::MAX)
- i64::try_from(self.scroll_top).unwrap_or(i64::MAX);
self.scroll_by_lines(delta)
}
/// Q#R1 — per-line incremental reshape for a single-line text
/// edit: rebuild ONE `BufferLine` instead of re-shaping the whole
/// visible slice. Returns `false` when the edit needs the full
/// `reshape` (slice origin moved, edited line outside the shaped
/// slice, exotic paragraph separators that the full path would
/// have split on). The caller has already established the edit is
/// single-line (line count unchanged, no `\n` inserted).
fn try_reshape_line(&mut self, edit: TextProjectionEdit) -> bool {
let (vstart, vend) = self.visible_byte_range();
if vstart != self.view_range.0 {
// The slice origin moved (edit before the viewport): the
// whole slice shifts; surgery can't help.
return false;
}
if edit.start >= vend {
// Entirely past the visible slice: no shaped line's
// content changes; offsets are clip-rebased per frame.
self.view_range = (vstart, vend);
self.hit_map_dirty = true;
self.request_redraw();
return true;
}
let line_idx = self
.current_line_starts
.partition_point(|&s| s <= edit.start)
.saturating_sub(1);
let line_start = self.current_line_starts[line_idx];
if line_start < vstart {
return false;
}
let next_start = self.current_line_starts.get(line_idx + 1).copied();
let content_end = next_start
.map_or(self.current_text.len() as u64, |n| n.saturating_sub(1))
.min(vend);
let Some(shaped_idx) = line_idx.checked_sub(self.shaped_top) else {
return false;
};
if shaped_idx >= self.buffer.lines.len() || shaped_idx >= self.line_chunk_cache.len() {
// E.g. typing on the phantom empty line after a trailing
// newline — no BufferLine exists for it; full reshape
// handles those shapes correctly.
return false;
}
let chunks = self.chunks_for_line(line_start, content_end);
self.buffer.lines[shaped_idx] = line_from_chunks(&chunks);
self.line_chunk_cache[shaped_idx] = chunks;
self.buffer.shape_until_scroll(&mut self.font_system, false);
self.view_range = (vstart, vend);
self.hit_map_dirty = true;
self.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<RichChunk> {
clipped_chunks_for_range(
&self.current_text,
&self.current_spans,
&self.current_adornments,
line_start,
content_end,
)
}
/// Rebuild the shaped slice, reusing any retained line whose
/// absolute index was already shaped (pure scroll: content and
/// styling unchanged for retained lines, their shape caches
/// survive — only newly exposed lines pay shaping). Falls back to
/// building everything when nothing overlaps. Every builder keeps
/// `line_chunk_cache` current, so reuse is always sound here.
fn rebuild_lines_reusing_scroll(&mut self) {
let (vstart, vend) = self.visible_byte_range();
self.view_range = (vstart, vend);
let (new_top, ranges) = self.slice_line_ranges(vstart, vend);
let old_top = self.shaped_top;
let mut old_lines: Vec<Option<glyphon::cosmic_text::BufferLine>> =
std::mem::take(&mut self.buffer.lines)
.into_iter()
.map(Some)
.collect();
let mut old_cache: Vec<Option<Vec<RichChunk>>> = std::mem::take(&mut self.line_chunk_cache)
.into_iter()
.map(Some)
.collect();
let mut lines = Vec::with_capacity(ranges.len());
let mut cache = Vec::with_capacity(ranges.len());
let mut any_reused = false;
for (i, &(ls, ce)) in ranges.iter().enumerate() {
let abs = new_top + i;
let reused = abs.checked_sub(old_top).and_then(|j| {
if j < old_lines.len() && j < old_cache.len() {
old_lines[j].take().zip(old_cache[j].take())
} else {
None
}
});
if let Some((line, chunks)) = reused {
any_reused = true;
lines.push(line);
cache.push(chunks);
} else {
let chunks = self.chunks_for_line(ls, ce);
lines.push(line_from_chunks(&chunks));
cache.push(chunks);
}
}
self.buffer.lines = lines;
self.line_chunk_cache = cache;
self.shaped_top = new_top;
self.buffer
.set_scroll(glyphon::cosmic_text::Scroll::default());
self.buffer.shape_until_scroll(&mut self.font_system, false);
self.hit_map_dirty = true;
if any_reused || self.line_chunk_cache.is_empty() {
self.styled_redraw_deadline = None;
self.request_redraw();
} else {
// Far jump (Q#M6, bet #2): every line rebuilt, and the
// span set covers the *old* viewport — drawing now would
// flash unstyled text. Hold the redraw until the restyle
// lands (`refresh_changed_lines` clears this) or the
// deadline fires in `about_to_wait`.
self.styled_redraw_deadline = Some(std::time::Instant::now() + JUMP_STYLE_HOLD);
}
}
/// Re-shape ONLY lines whose chunk set changed — the incoming
/// frame path (`StyleSpans` / fg `Decorations` / `InlineAdornments`).
/// A parse-settle frame after a typing burst usually recolors a
/// line or two; re-shaping the whole slice for it was a full
/// keystroke-cost stall.
fn refresh_changed_lines(&mut self) {
let (vstart, vend) = self.visible_byte_range();
let (top, ranges) = self.slice_line_ranges(vstart, vend);
if (vstart, vend) != self.view_range
|| top != self.shaped_top
|| ranges.len() != self.line_chunk_cache.len()
|| ranges.len() != self.buffer.lines.len()
{
self.reshape();
return;
}
let mut any = false;
for (i, &(ls, ce)) in ranges.iter().enumerate() {
let chunks = self.chunks_for_line(ls, ce);
if chunks != self.line_chunk_cache[i] {
self.buffer.lines[i] = line_from_chunks(&chunks);
self.line_chunk_cache[i] = chunks;
any = true;
}
}
if any {
self.buffer.shape_until_scroll(&mut self.font_system, false);
self.hit_map_dirty = true;
}
// Fresh styling reached the slice — release any held
// post-jump frame (Q#M6, bet #2).
self.styled_redraw_deadline = None;
self.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
/// [`Self::compose_status_left`]'s priority: minibuffer/isearch
/// content follows `ui.minibuffer`, a transient message follows
/// `ui.statusline`, and the buffer name follows `ui.modeline`
/// (the framing's content-class applicability, Q#TH3).
fn status_left_color(&self) -> Color {
const LEFT_DEFAULT: (u8, u8, u8) = (200, 200, 210);
let fallback = Color::rgb(LEFT_DEFAULT.0, LEFT_DEFAULT.1, LEFT_DEFAULT.2);
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, |(_, t)| t)
}
/// Compose the status-band readout (Q#S1): diagnostic counts
/// (wire-authoritative, severity-colored, omitted when zero),
/// then cursor L:C from the *optimistic* caret (so it tracks
/// typing bursts instead of lagging a round trip), then the
/// All/Top/Bot/NN% scroll indicator. Returns the colored spans.
fn compose_status_spans(&self) -> Vec<(String, Option<Color>)> {
use std::fmt::Write as _;
let mut spans: Vec<(String, Option<Color>)> = Vec::new();
if let Some(facts) = self
.status_facts
.as_ref()
.filter(|f| Some(f.buffer_id) == self.current_buffer_id)
{
if facts.diag_errors > 0 {
// Themes Q#TH5: the counters follow the diag faces
// (fg mask; the shaping-cache invalidation in the
// ThemeFacts arm makes a recolor with constant counts
// actually re-shape, Q#TH8).
spans.push((
format!("E:{}", facts.diag_errors),
Some(self.diag_face_fg_or("ui.diag.error", Color::rgb(241, 76, 76))),
));
}
if facts.diag_warnings > 0 {
spans.push((
format!("W:{}", facts.diag_warnings),
Some(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(|&s| s as usize <= byte)
.saturating_sub(1);
cursor_row = line;
let ls = self.current_line_starts.get(line).copied().unwrap_or(0) as usize;
let col = self
.current_text
.get(ls..byte)
.map_or(0, |s| s.chars().count());
let _ = write!(readout, "L{}:C{}", line + 1, col + 1);
readout.push_str(" ");
}
readout.push_str(&format_scroll_indicator(
self.scroll_top,
estimated_visible_lines(self.config.height),
self.current_line_starts.len(),
cursor_row,
));
spans.push((readout, None));
spans
}
/// The band's left side. While an incremental search is running
/// (Q#SR5) it shows `I-search: <query> (n/m)` — the prompt takes
/// over the band like Emacs's echo area, returning to the buffer
/// name + modified dot (v8 `StatusFacts`) when the search ends.
fn compose_status_left(&self) -> String {
// Q#MB1 — an open minibuffer takes over the band: prompt + input
// (the candidates render separately as a dropdown). Measured by
// the band caret, so it must stay exactly `prompt + input`.
if let Some(mb) = self.minibuffer.as_ref() {
return format!("{}{}", mb.prompt, mb.input);
}
if let Some(sp) = self
.search_prompt
.as_ref()
.filter(|s| Some(s.buffer_id) == self.current_buffer_id)
{
let label = if sp.regex {
"Regex I-search: "
} else {
"I-search: "
};
let count = if sp.query.is_empty() {
String::new()
} else if sp.invalid {
" [invalid]".to_string()
} else if sp.total == 0 {
" [no match]".to_string()
} else {
format!(" ({}/{})", sp.active.map_or(0, |a| a + 1), sp.total)
};
return format!("{}{}{}", label, sp.query, count);
}
// A transient status message (v15 `StatusFacts.message` — LSP
// command summaries like "12 references", error reports) takes
// the band over echo-area style; the daemon clears it on the
// next keypress, which ships a fresh `StatusFacts` and returns
// the band to the buffer name.
if let Some(msg) = self
.status_facts
.as_ref()
.filter(|f| Some(f.buffer_id) == self.current_buffer_id)
.and_then(|f| f.message.as_deref())
{
return msg.to_owned();
}
match self
.status_facts
.as_ref()
.filter(|f| Some(f.buffer_id) == self.current_buffer_id)
{
Some(facts) if facts.modified => format!("{}", facts.name),
Some(facts) => facts.name.clone(),
None => String::new(),
}
}
/// Re-shape the status-band text iff the composed content
/// changed (short lines — shaping is trivial, but not free per
/// frame).
fn refresh_status_line(&mut self) {
let spans = self.compose_status_spans();
let composed: String = spans
.iter()
.map(|(t, _)| t.as_str())
.collect::<Vec<_>>()
.join(" ");
let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
if composed != self.status_text {
let mut rich: Vec<(&str, Attrs)> = Vec::new();
for (i, (t, c)) in spans.iter().enumerate() {
if i > 0 {
rich.push((" ", default_attrs.clone()));
}
let attrs = match c {
Some(color) => default_attrs.clone().color(*color),
None => default_attrs.clone(),
};
rich.push((t.as_str(), attrs));
}
self.status_buffer.set_rich_text(
&mut self.font_system,
rich,
&default_attrs,
Shaping::Advanced,
None,
);
self.status_buffer
.shape_until_scroll(&mut self.font_system, false);
self.status_text = composed;
}
let left = self.compose_status_left();
if left != self.status_left_text {
self.status_left_buffer.set_text(
&mut self.font_system,
&left,
&default_attrs,
Shaping::Advanced,
None,
);
self.status_left_buffer
.shape_until_scroll(&mut self.font_system, false);
self.status_left_text = left;
}
}
/// The status band's background quad (Q#S2): a full-width strip
/// under the band text.
fn status_band_vertex_bytes(&self) -> Vec<u8> {
// Themes Q#TH5: a set ui.modeline face owns the band surface.
let color = self
.modeline_face_colors()
.map_or(STATUS_BAND_BG, |(quad, _)| quad);
let rect = MinimapRect {
x: 0.0,
y: text_area_bottom(self.config.height),
w: self.config.width as f32,
h: 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::<Vec<_>>()
.join("\n")
});
self.menu_buffer.set_text(
&mut self.font_system,
&text,
&Attrs::new().family(Family::Name("JetBrains Mono")),
Shaping::Advanced,
None,
);
self.menu_buffer
.shape_until_scroll(&mut self.font_system, false);
}
/// Popup background, active-row highlight, and separator quads
/// (Q#CM1). Empty when the menu is closed.
fn menu_vertex_bytes(&self) -> Vec<u8> {
let Some(menu) = self.menu.as_ref() else {
return Vec::new();
};
let ax = menu.anchor_px.0 as f32;
let ay = menu.anchor_px.1 as f32;
let w = Self::menu_width_px(menu);
let mut rects = vec![MinimapRect {
x: ax,
y: ay,
w,
h: menu.rows.len() as f32 * MENU_ROW_HEIGHT,
color: MENU_BG,
}];
for (i, row) in menu.rows.iter().enumerate() {
let ry = ay + i as f32 * MENU_ROW_HEIGHT;
if row.separator {
rects.push(MinimapRect {
x: ax + MENU_PAD_X,
y: ry + MENU_ROW_HEIGHT / 2.0 - 0.5,
w: w - 2.0 * MENU_PAD_X,
h: 1.0,
color: MENU_SEPARATOR_BG,
});
} else if menu.active == Some(i as u32) {
rects.push(MinimapRect {
x: ax,
y: ry,
w,
h: MENU_ROW_HEIGHT,
color: MENU_SELECTED_BG,
});
}
}
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Re-shape the minibuffer dropdown candidates (Q#MB1), one line per
/// candidate, best match first. Empty when there are no candidates.
fn refresh_mb_buffer(&mut self) {
let text = self
.minibuffer
.as_ref()
.map_or_else(String::new, |mb| mb.candidates.join("\n"));
self.mb_buffer.set_text(
&mut self.font_system,
&text,
&Attrs::new().family(Family::Name("JetBrains Mono")),
Shaping::Advanced,
None,
);
self.mb_buffer
.shape_until_scroll(&mut self.font_system, false);
}
/// The visible slice `(first, count)` of the dropdown candidates
/// (audit F-007), clamped to the rows that fit above the band and
/// scrolled to keep the selection on screen. `None` when closed,
/// candidate-free, or too short for a row. See [`mb_dropdown_window`].
fn mb_visible_window(&self) -> Option<(usize, usize)> {
let mb = self.minibuffer.as_ref()?;
let band_top = text_area_bottom(self.config.height);
mb_dropdown_window(
mb.candidates.len(),
mb.selected.map_or(0, |s| s as usize),
band_top,
)
}
/// Dropdown geometry `(left, top_y, width)` when the minibuffer has
/// candidates: a list anchored just above the bottom band, growing
/// upward, as wide as the widest candidate (clamped). `None` when
/// closed or candidate-free. The height is the *visible* row count
/// (F-007), so `top_y` never goes above the window top.
/// `refresh_mb_buffer` must have run so the width measurement is
/// current.
fn mb_dropdown_rect(&self) -> Option<(f32, f32, f32)> {
let (_first, count) = self.mb_visible_window()?;
let widest = self
.mb_buffer
.layout_runs()
.map(|r| r.line_w)
.fold(0.0_f32, f32::max);
let width = (widest + 2.0 * MB_DROP_PAD_X).clamp(MB_DROP_MIN_WIDTH, MB_DROP_MAX_WIDTH);
let band_top = text_area_bottom(self.config.height);
let top_y = band_top - count as f32 * MB_DROP_ROW_HEIGHT;
Some((STATUS_TEXT_PAD, top_y, width))
}
/// Minibuffer dropdown background + selection-highlight quads (Q#MB1).
/// Empty when closed / candidate-free.
fn mb_dropdown_vertex_bytes(&self) -> Vec<u8> {
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 * 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 * MB_DROP_ROW_HEIGHT,
w: width,
h: MB_DROP_ROW_HEIGHT,
color: MENU_SELECTED_BG,
});
}
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// 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::<Vec<_>>()
.join("\n")
});
self.completion_buffer.set_text(
&mut self.font_system,
&text,
&Attrs::new().family(Family::Name("JetBrains Mono")),
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(&self) -> Option<(f32, f32, f32)> {
if !self.completion_open_for_current_buffer() {
return None; // never paint against a foreign buffer's rope
}
let comp = self.completion.as_ref()?;
let (vstart, vend) = self.view_range;
if vend <= vstart {
return None;
}
let anchor = comp.anchor;
if anchor < vstart || anchor > vend {
return None;
}
let slice = &self.current_text[vstart as usize..vend as usize];
let line_offsets = line_byte_offsets(slice);
let slice_anchor = anchor - vstart;
let (line_lo, _) = source_line_range(slice, slice_anchor);
let text_left = self.text_left();
for run in self.buffer.layout_runs() {
if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo {
continue;
}
let mut x = text_left;
for glyph in run.glyphs {
if line_lo + glyph.start as u64 >= slice_anchor {
x = text_left + glyph.x;
break;
}
// Anchor is past this glyph; track its right edge so an
// anchor at line end lands after the final glyph.
x = text_left + glyph.x + glyph.w;
}
return Some((x, TEXT_TOP + run.line_top, run.line_height));
}
None
}
/// 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(&self) -> Option<(usize, usize, f32, f32)> {
let comp = self.completion.as_ref()?;
let n = comp.rows.len();
if n == 0 {
return None;
}
let (ax, line_top, line_h) = self.completion_anchor_px()?;
let band_top = text_area_bottom(self.config.height);
let below_px = band_top - (line_top + line_h);
let above_px = line_top - TEXT_TOP;
let max_below = (below_px / MB_DROP_ROW_HEIGHT).floor() as usize;
let max_above = (above_px / 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 sel = comp.selected.map_or(0, |s| s as usize);
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 * 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(&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(&self) -> Vec<u8> {
let Some(comp) = self.completion.as_ref() 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 * MB_DROP_ROW_HEIGHT,
color: MENU_BG,
}];
if let Some(sel) = comp.selected.map(|s| s as usize)
&& sel >= first
&& sel < first + count
{
rects.push(MinimapRect {
x,
y: top_y + (sel - first) as f32 * MB_DROP_ROW_HEIGHT,
w: width,
h: MB_DROP_ROW_HEIGHT,
color: MENU_SELECTED_BG,
});
}
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Bookkeeping for an outgoing Pointer event: it supersedes any
/// unconfirmed optimistic-cursor prediction (the daemon's answer
/// will be the click position, not the typing prediction), and
/// the cursor is not authoritative again until that `CursorByte`
/// lands.
fn note_pointer_round_trip(&mut self) {
self.cursor_fresh = false;
self.optimistic_cursor_floor = None;
self.optimistic_floor_set_at = None;
}
/// Frontend-side multi-click detection: a second Down at the
/// same hit byte within the interval upgrades to `DoubleDown`,
/// a third to `TripleDown` (Q#M4); a fourth restarts the chain.
fn classify_pointer_down(&mut self, byte: u64, shift: bool) -> PointerKind {
if shift {
// Shift-click extends the selection (Q#M5); it neither
// advances nor inherits the multi-click chain — two
// Shift-clicks must not become a word select.
self.last_pointer_down = None;
return PointerKind::Down;
}
let now = std::time::Instant::now();
let prior_chain = self
.last_pointer_down
.take()
.and_then(|(at, prev, count)| {
(prev == byte && now.duration_since(at) <= DOUBLE_CLICK_WINDOW).then_some(count)
})
.unwrap_or(0);
match prior_chain {
0 => {
self.last_pointer_down = Some((now, byte, 1));
PointerKind::Down
}
1 => {
self.last_pointer_down = Some((now, byte, 2));
PointerKind::DoubleDown
}
_ => {
// Chain consumed: a fourth click starts over.
PointerKind::TripleDown
}
}
}
fn apply_file_style_summary(
&mut self,
buffer_id: BufferId,
generation: u64,
lines: Vec<CellStyle>,
) {
if self.current_buffer_id != Some(buffer_id) {
return;
}
if self
.current_summary
.as_ref()
.is_some_and(|summary| generation < summary.generation)
{
return;
}
self.current_line_shapes = minimap_line_shapes(&self.current_text);
self.current_summary = Some(FileStyleSummaryState { generation, lines });
// 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.request_redraw();
}
/// `full = true` path: discard prior styling, take the segments'
/// spans as authoritative for the declared viewport.
fn replace_style_spans(&mut self, segments: Vec<StyleSegment>) {
self.current_spans.clear();
for seg in segments {
self.current_spans.extend(seg.spans);
}
self.current_spans.sort_by_key(|s| s.range.start);
}
/// `full = false` path: each segment's `range` authoritatively
/// replaces styling within it. Spans fully inside any dirty range
/// drop; spans straddling a dirty edge get clipped to outside the
/// range; the new spans are appended; finally everything sorts.
///
/// This is exactly the surface bet #1 from the framing pass
/// predicted ("dirty-segment edges at viewport boundaries —
/// headless-test-blind-spot probe"). Per-byte adversarial
/// behavior here lives in the user-side validation, not in unit
/// tests — that's the design-doc framing's whole point.
fn merge_style_spans(&mut self, segments: Vec<StyleSegment>) {
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<DecorationSegment>) {
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<T: HasRange>` 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<DecorationSegment>) {
for seg in &segments {
let dirty = seg.range;
let mut kept = Vec::with_capacity(self.current_decorations.len());
for d in self.current_decorations.drain(..) {
if d.range.end <= dirty.start || d.range.start >= dirty.end {
kept.push(d);
} else if d.range.start < dirty.start && d.range.end > dirty.end {
kept.push(Decoration {
range: ByteRange {
start: d.range.start,
end: dirty.start,
},
kind: d.kind,
});
kept.push(Decoration {
range: ByteRange {
start: dirty.end,
end: d.range.end,
},
kind: d.kind,
});
} else if d.range.start < dirty.start {
kept.push(Decoration {
range: ByteRange {
start: d.range.start,
end: dirty.start,
},
kind: d.kind,
});
} else if d.range.end > dirty.end {
kept.push(Decoration {
range: ByteRange {
start: dirty.end,
end: d.range.end,
},
kind: d.kind,
});
}
}
self.current_decorations = kept;
}
for seg in segments {
self.current_decorations.extend(seg.decorations);
}
self.current_decorations.sort_by_key(|d| d.range.start);
}
/// Re-build the cosmic-text Buffer from `current_text` +
/// `current_spans` + `current_decorations` +
/// `current_adornments`. Source styling/decorations remain
/// byte-indexed into `current_text`; adornments contribute extra
/// rich-text chunks at their anchors without mutating the source
/// string. That display projection is the central session-6
/// invariant: virtual text must not shift the source-byte ranges
/// used by `StyleSpans` / `Decorations`.
///
/// Complexity is O(B × (S + D)) per reshape where B is the boundary
/// count and S+D is spans+decorations. For viewport-scoped data
/// this is bounded by visible bytes. A sweep-line refactor with
/// active-set pointers is the obvious upgrade if reshape cost
/// surfaces in profile data — recorded but not done in session 5.
/// Whole-file byte range `[vstart, vend)` of the source lines that
/// should be shaped: from `scroll_top` through the visible window
/// plus a small overscan (Q#S1/S3). Both ends fall on line
/// boundaries (cosmic-text splits `BufferLine`s on `\n`, so a
/// mid-line slice would corrupt the first/last line).
fn visible_byte_range(&self) -> (u64, u64) {
let line_starts = &self.current_line_starts;
let n = line_starts.len();
let top = self.scroll_top.min(n.saturating_sub(1));
let span = estimated_visible_lines(self.config.height).max(1) + SCROLL_OVERSCAN;
let vstart = line_starts[top];
let bottom = top.saturating_add(span).min(n);
let vend = if bottom < n {
line_starts[bottom]
} else {
self.current_text.len() as u64
};
(vstart, vend)
}
fn reshape(&mut self) {
// Session S1 — shape only the visible byte slice. Feeding the
// whole rope to `set_rich_text` (a BufferLine per source line)
// made large-file editing O(file) per keystroke; cosmic-text
// touches only `current_text[vstart..vend]` now. Spans /
// decorations / adornments arrive in whole-file coordinates and
// are clipped + rebased onto the slice (subtract `vstart`).
let (vstart, vend) = self.visible_byte_range();
self.view_range = (vstart, vend);
let (top, ranges) = self.slice_line_ranges(vstart, vend);
let mut lines = Vec::with_capacity(ranges.len());
let mut cache = Vec::with_capacity(ranges.len());
for &(ls, ce) in &ranges {
let chunks = self.chunks_for_line(ls, ce);
lines.push(line_from_chunks(&chunks));
cache.push(chunks);
}
self.buffer.lines = lines;
self.line_chunk_cache = cache;
self.shaped_top = top;
self.buffer
.set_scroll(glyphon::cosmic_text::Scroll::default());
self.buffer.shape_until_scroll(&mut self.font_system, false);
// The pointer hit map rebuilds lazily from the same caches
// (Q#R2) — clicks are rare next to keystrokes/frames.
self.hit_map_dirty = true;
// Full restyle: release any held post-jump frame (Q#M6).
self.styled_redraw_deadline = None;
self.request_redraw();
}
/// Ask the window to repaint. A no-op headless (no window), where the
/// render tests drive `render_offscreen` directly (F-014).
fn request_redraw(&self) {
if let Some(window) = &self.window {
window.request_redraw();
}
}
fn resize(&mut self, width: u32, height: u32) -> Option<ViewportSend> {
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 });
self.buffer.set_size(
&mut self.font_system,
Some(width as f32),
Some(height as f32),
);
self.status_buffer.set_size(
&mut self.font_system,
Some(width as f32),
Some(STATUS_BAND_HEIGHT),
);
self.status_left_buffer.set_size(
&mut self.font_system,
Some(width as f32),
Some(STATUS_BAND_HEIGHT),
);
// UX gutter: resize the line-number buffer too, else it keeps its
// construction-time (800x200) height and `shape_until_scroll` only
// shapes the ~10 lines that fit — the "numbers stop at 10" bug.
self.gutter_buffer.set_size(
&mut self.font_system,
Some(width as f32),
Some(height as f32),
);
// A taller/shorter window changes the visible line count, so the
// slice + scoped viewport change (session S1).
self.reshape();
self.request_redraw();
self.current_buffer_id
.and_then(|bid| self.viewport_send_if_changed(bid))
}
/// Acquire the surface's current texture and render into it — the live
/// windowed path. Composition lives in `render_to_view`, shared with
/// the headless offscreen path (`render_offscreen`, F-014).
fn render(&mut self) {
let frame = {
let Some(surface) = self.surface.as_ref() else {
return;
};
match surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(frame)
| wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => {
surface.configure(&self.device, &self.config);
return;
}
wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
return;
}
wgpu::CurrentSurfaceTexture::Validation => {
eprintln!("surface acquisition raised a validation error");
return;
}
}
};
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
self.render_to_view(&view);
frame.present();
}
/// Render one frame to an offscreen texture and read it back as packed
/// RGBA8 (`width * height * 4` bytes, row padding removed). Test-only,
/// the entry point for the headless render harness (F-014).
#[cfg(test)]
fn render_offscreen(&mut self) -> Vec<u8> {
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();
// The band's strip rides the bg quad batch so it draws under
// the band text (text renders after the first quad draw).
let mut bg_vertices = self.decoration_background_vertex_bytes();
bg_vertices.extend(self.status_band_vertex_bytes());
let bg_vertex_count = (bg_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
let bg_buffer = self
.bg_vertex_buffer
.upload(
&self.device,
&self.queue,
"pmacs-gpu decoration backgrounds",
&bg_vertices,
)
.cloned();
// Diagnostic squiggles (Q#W1): own pipeline + buffer, drawn
// between the wash quads and the text (under the glyphs, the
// z-slot the straight bar held).
let squiggle_vertices = self.squiggle_vertex_bytes();
let squiggle_vertex_count =
(squiggle_vertices.len() / SQUIGGLE_VERTEX_STRIDE as usize) as u32;
let squiggle_buffer = self
.squiggle_vertex_buffer
.upload(
&self.device,
&self.queue,
"pmacs-gpu diagnostic squiggles",
&squiggle_vertices,
)
.cloned();
let caret_vertices = self.caret_vertex_bytes();
let caret_vertex_count = (caret_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
let caret_buffer = self
.caret_vertex_buffer
.upload(
&self.device,
&self.queue,
"pmacs-gpu caret",
&caret_vertices,
)
.cloned();
let after_bg = debug_frame().then(std::time::Instant::now);
// Minimap quads depend only on (summary, size, scroll); cache
// the vertex bytes instead of rescanning every line shape per
// frame.
let minimap_key = (
self.current_summary.as_ref().map_or(0, |s| s.generation),
self.config.width,
self.config.height,
self.scroll_top,
);
if self
.minimap_cache
.as_ref()
.is_none_or(|(key, _)| *key != minimap_key)
{
self.minimap_cache = Some((minimap_key, self.minimap_vertex_bytes()));
}
let minimap_vertices = &self.minimap_cache.as_ref().expect("just filled").1;
let minimap_vertex_count = (minimap_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32;
let minimap_buffer = self
.minimap_vertex_buffer
.upload(
&self.device,
&self.queue,
"pmacs-gpu minimap vertices",
minimap_vertices,
)
.cloned();
let after_minimap = debug_frame().then(std::time::Instant::now);
let text_bounds_right = self.text_bounds_right();
// Right-align the status readout: measure the shaped width
// and place the area flush to the right pad (Q#S2).
let status_width = self
.status_buffer
.layout_runs()
.map(|r| r.line_w)
.fold(0.0_f32, f32::max);
let status_left =
(self.config.width as f32 - STATUS_TEXT_PAD - status_width).max(TEXT_LEFT);
let status_top =
text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0;
// 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();
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));
self.text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
[
TextArea {
buffer: &self.buffer,
left: text_left,
top: TEXT_TOP,
scale: 1.0,
bounds: TextBounds {
left: gutter_clip_left,
top: 0,
right: text_bounds_right,
// Clip at the status band (Q#S3): a final
// partially-visible line must not bleed
// into the band.
bottom: text_area_bottom(self.config.height).round() as i32,
},
default_color: Color::rgb(230, 230, 235),
custom_glyphs: &[],
},
TextArea {
buffer: &self.status_buffer,
left: status_left,
top: status_top,
scale: 1.0,
bounds: TextBounds {
left: 0,
top: text_area_bottom(self.config.height).round() as i32,
right: self.config.width.cast_signed(),
bottom: self.config.height.cast_signed(),
},
// Themes Q#TH5: a set ui.modeline face colors
// the readout too (its fg after the reverse
// swap); unset keeps the dimmer gray.
default_color: readout_color,
custom_glyphs: &[],
},
TextArea {
buffer: &self.status_left_buffer,
left: STATUS_TEXT_PAD,
top: status_top,
scale: 1.0,
bounds: TextBounds {
left: 0,
top: text_area_bottom(self.config.height).round() as i32,
// Stop before the right-aligned readout.
right: (status_left - STATUS_TEXT_PAD).max(0.0).round() as i32,
bottom: self.config.height.cast_signed(),
},
// Themes Q#TH3: the left segment's face follows
// its CONTENT class (minibuffer/isearch →
// ui.minibuffer; message → ui.statusline; name
// → ui.modeline).
default_color: left_color,
custom_glyphs: &[],
},
],
&mut self.swash_cache,
)
.expect("text_renderer prepare");
// UX gutter: prepare the line-number layer in the reserved left
// strip (empty when off → renders nothing). Same `top` + line
// height as the code, so numbers align row-for-row.
let gutter_areas: Vec<TextArea> = if self.line_numbers.is_on() {
vec![TextArea {
buffer: &self.gutter_buffer,
left: TEXT_LEFT,
top: TEXT_TOP,
scale: 1.0,
bounds: TextBounds {
left: 0,
top: 0,
right: gutter_clip_left,
bottom: text_area_bottom(self.config.height).round() as i32,
},
// Themes Q#TH5: ui.gutter's {fg} mask colors the digits.
default_color: gutter_color,
custom_glyphs: &[],
}]
} else {
Vec::new()
};
self.gutter_text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
gutter_areas,
&mut self.swash_cache,
)
.expect("gutter_text_renderer prepare");
// Q#CM1 — prepare the menu glyphs in their own layer (empty when
// closed, so the renderer draws nothing).
let menu_areas: Vec<TextArea> = self
.menu
.as_ref()
.map(|menu| {
let ax = menu.anchor_px.0 as f32;
let ay = menu.anchor_px.1 as f32;
TextArea {
buffer: &self.menu_buffer,
left: ax + MENU_PAD_X,
top: ay + 2.0,
scale: 1.0,
bounds: TextBounds {
left: ax as i32,
top: ay as i32,
right: (ax + Self::menu_width_px(menu)).round() as i32,
bottom: (ay + menu.rows.len() as f32 * MENU_ROW_HEIGHT).round() as i32,
},
default_color: Color::rgb(232, 232, 238),
custom_glyphs: &[],
}
})
.into_iter()
.collect();
self.menu_text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
menu_areas,
&mut self.swash_cache,
)
.expect("menu text_renderer prepare");
// Q#MB1 — prepare the minibuffer dropdown glyphs in their layer.
// The buffer is shaped with *all* candidates; F-007 scrolls it up
// by `first` rows so line `first` lands at `top_y`, and the
// existing `bounds.top`/`bottom` clip the rows scrolled out of the
// visible window (no per-resize re-shape needed).
// Hoisted for the same borrow reason as the band colors above.
let candidate_color = self.face_fg_or("ui.minibuffer.candidate", Color::rgb(232, 232, 238));
let mb_areas: Vec<TextArea> = self
.mb_visible_window()
.zip(self.mb_dropdown_rect())
.map(|((first, _count), (x, top_y, width))| TextArea {
buffer: &self.mb_buffer,
left: x + MB_DROP_PAD_X,
top: top_y - first as f32 * MB_DROP_ROW_HEIGHT,
scale: 1.0,
bounds: TextBounds {
left: x as i32,
top: top_y as i32,
right: (x + width).round() as i32,
bottom: text_area_bottom(self.config.height).round() as i32,
},
// Themes Q#TH5 (round 3 finding 1): the candidate
// glyph layer is ui.minibuffer.candidate's GPU site;
// the popup bg/selection quads stay chrome constants.
default_color: candidate_color,
custom_glyphs: &[],
})
.into_iter()
.collect();
self.mb_text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
mb_areas,
&mut self.swash_cache,
)
.expect("minibuffer text_renderer prepare");
// Arc 1a Q#C5 — prepare the completion dropdown glyphs in their
// layer. The buffer is shaped with *all* wire rows; the layout
// scrolls it up by `first` rows so row `first` lands at `top_y`,
// and `bounds` clips the rows outside the visible window (the
// minibuffer dropdown's F-007 shape).
let completion_areas: Vec<TextArea> = self
.completion_dropdown_layout()
.zip(self.completion_dropdown_rect())
.map(|((first, count, _ax, _ty), (x, top_y, width))| TextArea {
buffer: &self.completion_buffer,
left: x + MB_DROP_PAD_X,
top: top_y - first as f32 * MB_DROP_ROW_HEIGHT,
scale: 1.0,
bounds: TextBounds {
left: x as i32,
top: top_y as i32,
right: (x + width).round() as i32,
bottom: (top_y + count as f32 * MB_DROP_ROW_HEIGHT).round() as i32,
},
default_color: Color::rgb(232, 232, 238),
custom_glyphs: &[],
})
.into_iter()
.collect();
self.completion_text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
completion_areas,
&mut self.swash_cache,
)
.expect("completion text_renderer prepare");
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("pmacs-gpu frame encoder"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("pmacs-gpu pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(BG),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
multiview_mask: None,
});
// Q#2 stance (α): single render pass, three draws. Quad
// backgrounds first (Selection today; CurrentLine in 9.2)
// so their translucent fills sit under the glyphs; text
// second so source/inlay color shows on top; minimap last
// so it draws over the right-margin text region.
if let Some(vertex_buffer) = bg_buffer.as_ref() {
self.quad_renderer
.render(&mut pass, vertex_buffer, bg_vertex_count);
}
// Diagnostic squiggles under the glyphs (Q#W1).
if let Some(vertex_buffer) = squiggle_buffer.as_ref() {
self.squiggle_renderer
.render(&mut pass, vertex_buffer, squiggle_vertex_count);
}
self.text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("text_renderer render");
// UX gutter: line numbers in the reserved left strip (empty
// layer when off).
self.gutter_text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("gutter_text_renderer render");
// Caret over the text so the insertion point reads on top
// of the glyph it sits before (session B1).
if let Some(vertex_buffer) = caret_buffer.as_ref() {
self.quad_renderer
.render(&mut pass, vertex_buffer, caret_vertex_count);
}
if let Some(vertex_buffer) = minimap_buffer.as_ref() {
self.quad_renderer
.render(&mut pass, vertex_buffer, minimap_vertex_count);
}
// Q#MB1 — the minibuffer dropdown draws above the band: bg +
// selection quads, then its candidate glyphs on top.
if let Some(vertex_buffer) = mb_bg_buffer.as_ref() {
self.quad_renderer
.render(&mut pass, vertex_buffer, mb_vertex_count);
}
self.mb_text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("minibuffer text_renderer render");
// Arc 1a Q#C5 — the completion dropdown floats over the
// code at its byte anchor: bg + selection quads, then its
// row glyphs on top (under the context menu, which stays
// the topmost surface).
if let Some(vertex_buffer) = completion_bg_buffer.as_ref() {
self.quad_renderer
.render(&mut pass, vertex_buffer, completion_vertex_count);
}
self.completion_text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("completion text_renderer render");
// Q#CM1 — the context menu draws last: its bg/highlight quads
// occlude everything beneath, then its glyphs on top.
if let Some(vertex_buffer) = menu_bg_buffer.as_ref() {
self.quad_renderer
.render(&mut pass, vertex_buffer, menu_vertex_count);
}
self.menu_text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("menu text_renderer render");
}
self.queue.submit(std::iter::once(encoder.finish()));
self.atlas.trim();
if let (Some(start), Some(after_bg), Some(after_minimap)) =
(frame_start, after_bg, after_minimap)
{
let end = std::time::Instant::now();
let us = |a: std::time::Instant, b: std::time::Instant| b.duration_since(a).as_micros();
eprintln!(
"pmacs-gpu frame: bg={}us minimap={}us prepare+submit={}us total={}us peers={}",
us(start, after_bg),
us(after_bg, after_minimap),
us(after_minimap, end),
us(start, end),
self.peer_presences.len(),
);
}
}
fn text_bounds_right(&self) -> i32 {
if self.has_minimap() {
minimap_left(self.config.width).map_or(self.config.width.cast_signed(), |left| {
(left - TEXT_RIGHT_GAP).max(TEXT_LEFT + 1.0).round() as i32
})
} else {
self.config.width.cast_signed()
}
}
fn has_minimap(&self) -> bool {
self.current_summary
.as_ref()
.is_some_and(|summary| !summary.lines.is_empty())
&& minimap_left(self.config.width).is_some()
}
fn minimap_vertex_bytes(&self) -> Vec<u8> {
let Some(summary) = self.current_summary.as_ref() else {
return Vec::new();
};
let visible_lines = estimated_visible_lines(self.config.height);
let rects = minimap_rects(
&summary.lines,
&self.current_line_shapes,
self.config.width,
self.config.height,
// The thumb tracks the live scroll position. (It was
// hardcoded to 0 from the minimap's first session —
// surfaced by Q#M6 validation, where jumping finally
// made the frozen thumb obvious.)
self.scroll_top,
visible_lines,
);
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Vertex bytes for quad-pipeline background washes (drawn *under*
/// the text). Two sources, both `Selection` / `CurrentLine`: this
/// frontend's *own* window decorations from `current_decorations`
/// (live again since session B1 reactivated the own cursor — Q#B4;
/// QB1 had suppressed them while the mirror was read-only), and
/// *peer* presence from `PresenceUpdate` (session 9.3). Both reuse
/// the same `\n`-line offset table to rebase cosmic-text's
/// line-relative glyph offsets (QB3). The caret is separate (drawn
/// *over* text) — see [`Self::caret_vertex_bytes`].
fn decoration_background_vertex_bytes(&self) -> Vec<u8> {
let Some(buffer_id) = self.current_buffer_id else {
return Vec::new();
};
let (vstart, vend) = self.view_range;
if vend <= vstart {
return Vec::new();
}
// Glyph offsets are relative to the *slice* the buffer holds
// (session S1), so the line table is computed on the slice and
// every whole-file byte range is clip-rebased onto it.
let slice = &self.current_text[vstart as usize..vend as usize];
let line_offsets = line_byte_offsets(slice);
let mut rects = Vec::new();
self.collect_own_decoration_rects(&mut rects, &line_offsets, vstart, vend);
self.collect_peer_rects(buffer_id, &line_offsets, vstart, vend, &mut rects);
self.collect_gutter_sign_rects(&mut rects, &line_offsets, vstart, vend);
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Per-visible-line diagnostic sign bars in the gutter (UX gutter
/// sub-arc 2): one severity-colored bar at the gutter's left edge for
/// each line carrying a diagnostic, most-severe winning. Only when the
/// gutter is on, mirroring the TUI (signs ride the line-number gutter).
/// The GPU analogue of the TUI's leading-column `E`/`W`/`I`/`H` glyph.
fn collect_gutter_sign_rects(
&self,
rects: &mut Vec<MinimapRect>,
line_offsets: &[u64],
vstart: u64,
vend: u64,
) {
if !self.line_numbers.is_on() {
return;
}
let slice_len = vend - vstart;
for run in self.buffer.layout_runs() {
let line_base = line_offsets.get(run.line_i).copied().unwrap_or(0);
let line_end = line_offsets
.get(run.line_i + 1)
.copied()
.unwrap_or(slice_len);
let mut best: Option<(u8, [f32; 4])> = None;
for d in &self.current_decorations {
let Some(rank) = diagnostic_severity_rank(d.kind) else {
continue;
};
let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend)
else {
continue;
};
if hi <= line_base || lo >= line_end {
continue; // decoration doesn't touch this line
}
if best.is_none_or(|(r, _)| rank < r)
&& let Some(color) = decoration_kind_to_underline_color(d.kind)
{
// Themes Q#TH5: gutter signs share the resolved
// severity color with the squiggles.
best = Some((rank, self.diag_face_rgba(d.kind, color)));
}
}
if let Some((_, color)) = best {
rects.push(MinimapRect {
x: GUTTER_SIGN_X,
y: TEXT_TOP + run.line_top,
w: GUTTER_SIGN_W,
h: run.line_height,
color,
});
}
}
}
/// Own-window `Selection` washes from `current_decorations`. The
/// caret already marks the own cursor, so the own *`CurrentLine`*
/// wash is deliberately NOT rendered — a whole-line highlight on
/// every cursor line reads as a persistent selection, which is not
/// wanted as default editor behavior (revising Q#B4: the caret is
/// the own-cursor indicator; the line wash isn't). Peer presence
/// still shows other frontends' lines via `collect_peer_rects`.
fn collect_own_decoration_rects(
&self,
rects: &mut Vec<MinimapRect>,
line_offsets: &[u64],
vstart: u64,
vend: u64,
) {
for d in &self.current_decorations {
if d.kind == DecorationKind::CurrentLine {
continue;
}
let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) else {
continue;
};
// Diagnostic underlines are squiggles now, drawn by their
// own pipeline (`squiggle_vertex_bytes`); only the solid
// washes belong in this quad batch. Own washes resolve
// their theme faces (Q#TH5); peers keep the constants.
if let Some(color) = self.own_wash_color(d.kind) {
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None);
}
}
}
/// Vertex bytes for diagnostic squiggles (Q#W1), drawn by the
/// dedicated [`SquiggleRenderer`] pipeline rather than the solid
/// quad batch. Geometry is the same bottom-hugging glyph-extent
/// band as the old straight bar — only the vertex *format* differs
/// (carries the `uv` the sine fragment shader needs).
fn squiggle_vertex_bytes(&self) -> Vec<u8> {
if self.current_buffer_id.is_none() {
return Vec::new();
}
let (vstart, vend) = self.view_range;
if vend <= vstart {
return Vec::new();
}
let slice = &self.current_text[vstart as usize..vend as usize];
let line_offsets = line_byte_offsets(slice);
let mut rects = Vec::new();
for d in &self.current_decorations {
let Some(color) = decoration_kind_to_underline_color(d.kind) else {
continue;
};
// Themes Q#TH5: squiggles follow the ui.diag.* faces
// (fg mask, Default ↦ the built-in severity constant).
let color = self.diag_face_rgba(d.kind, color);
if let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) {
self.push_glyph_extent_rects(
&mut rects,
&line_offsets,
lo,
hi,
color,
Some(DIAG_SQUIGGLE_PX),
);
}
}
squiggles_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Peer cursor-line + selection washes from `PresenceUpdate`
/// (session 9.3). Single-peer mirrors reuse the `Selection` /
/// `CurrentLine` colors; per-peer distinct colors are deferred.
fn collect_peer_rects(
&self,
buffer_id: BufferId,
line_offsets: &[u64],
vstart: u64,
vend: u64,
rects: &mut Vec<MinimapRect>,
) {
let text_len = self.current_text.len() as u64;
for presence in self.peer_presences.values() {
if presence.buffer_id != buffer_id {
continue;
}
if let Some(color) = decoration_kind_to_bg_color(DecorationKind::CurrentLine) {
let (lo, hi) = source_line_range(&self.current_text, presence.cursor);
if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) {
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None);
}
}
if let Some(sel) = presence.selection
&& let Some(color) = decoration_kind_to_bg_color(DecorationKind::Selection)
{
let lo = sel.anchor.min(sel.active).min(text_len);
let hi = sel.anchor.max(sel.active).min(text_len);
if let Some((lo, hi)) = clip_rebase_range(lo, hi, vstart, vend) {
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None);
}
}
}
}
/// Vertex bytes for the caret quad, drawn *over* the text (B1).
/// Empty when no own cursor is known, it's in another buffer, or it
/// is scrolled out of the visible slice.
fn caret_vertex_bytes(&self) -> Vec<u8> {
// Q#MB1 — while the minibuffer is open the caret lives in the
// band at the input cursor, not in the buffer.
if self.minibuffer.is_some() {
return self
.minibuffer_caret_rect()
.map(|r| rects_to_vertex_bytes(&[r], self.config.width, self.config.height))
.unwrap_or_default();
}
let (vstart, vend) = self.view_range;
if vend <= vstart {
return Vec::new();
}
let slice = &self.current_text[vstart as usize..vend as usize];
let line_offsets = line_byte_offsets(slice);
let Some(rect) = self.caret_rect(slice, &line_offsets, vstart, vend) else {
return Vec::new();
};
rects_to_vertex_bytes(&[rect], self.config.width, self.config.height)
}
/// The caret rectangle for an open minibuffer (Q#MB1): a thin bar in
/// the bottom band at the input cursor. The band font is monospace,
/// so the per-char advance is the shaped status-left width divided by
/// its char count; the caret sits `prompt_chars + cursor` advances
/// from the band's left pad.
fn minibuffer_caret_rect(&self) -> Option<MinimapRect> {
let mb = self.minibuffer.as_ref()?;
let line_w = self
.status_left_buffer
.layout_runs()
.map(|r| r.line_w)
.fold(0.0_f32, f32::max);
let chars = mb.prompt.chars().count() + mb.input.chars().count();
let advance = if chars > 0 {
line_w / chars as f32
} else {
0.0
};
let cursor_chars = mb.prompt.chars().count() as f32 + mb.cursor as f32;
let status_top =
text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0;
Some(MinimapRect {
x: STATUS_TEXT_PAD + advance * cursor_chars,
y: status_top,
w: CARET_WIDTH,
h: STATUS_LINE_HEIGHT,
color: CARET_COLOR,
})
}
/// The caret rectangle for the own cursor, in slice coordinates: a
/// thin bar at the left edge of the glyph the cursor sits before (or
/// the right edge of the last glyph at line end). `None` when the
/// cursor is outside the visible slice. Byte→glyph mapping rebases
/// per line (QB3); the cursor is rebased onto the slice first (S1).
fn caret_rect(
&self,
slice: &str,
line_offsets: &[u64],
vstart: u64,
vend: u64,
) -> Option<MinimapRect> {
let own = self.own_cursor?;
if self.current_buffer_id != Some(own.buffer_id) {
return None;
}
let cursor = own.byte;
if cursor < vstart || cursor > vend {
return None; // scrolled off-screen
}
let slice_cursor = cursor - vstart;
let (line_lo, _) = source_line_range(slice, slice_cursor);
// UX gutter: the caret sits in the code area, past the gutter.
let text_left = self.text_left();
for run in self.buffer.layout_runs() {
if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo {
continue;
}
let line_base = line_lo;
let mut x = text_left;
for glyph in run.glyphs {
if line_base + glyph.start as u64 >= slice_cursor {
x = text_left + glyph.x;
break;
}
// Cursor is past this glyph; track its right edge so a
// cursor at line end lands after the final glyph.
x = text_left + glyph.x + glyph.w;
}
return Some(MinimapRect {
x,
y: TEXT_TOP + run.line_top,
w: CARET_WIDTH,
h: run.line_height,
color: CARET_COLOR,
});
}
None
}
/// Push one rect per visual line whose glyphs overlap the
/// buffer-absolute byte range `[lo, hi)`, spanning the matching
/// glyphs' horizontal extent. A range crossing visual-line
/// boundaries (wrapped or multi-line) fans out into one rect per
/// run. `line_offsets[run.line_i]` rebases the run's line-relative
/// glyph offsets into buffer-absolute space for the comparison.
fn push_glyph_extent_rects(
&self,
rects: &mut Vec<MinimapRect>,
line_offsets: &[u64],
lo: u64,
hi: u64,
color: [f32; 4],
bar_px: Option<f32>,
) {
if hi <= lo {
return;
}
// UX gutter: washes/squiggles are code-relative, past the gutter.
let text_left = self.text_left();
for run in self.buffer.layout_runs() {
let line_base = line_offsets.get(run.line_i).copied().unwrap_or(0);
let mut min_x: Option<f32> = None;
let mut max_x: Option<f32> = None;
for glyph in run.glyphs {
let g_start = line_base + glyph.start as u64;
let g_end = line_base + glyph.end as u64;
if g_end <= lo || g_start >= hi {
continue;
}
let x0 = glyph.x;
let x1 = glyph.x + glyph.w;
min_x = Some(min_x.map_or(x0, |v| v.min(x0)));
max_x = Some(max_x.map_or(x1, |v| v.max(x1)));
}
if let (Some(x0), Some(x1)) = (min_x, max_x)
&& x1 > x0
{
// `bar_px`: a band hugging the bottom of the line box
// instead of a full-height wash — the diagnostic
// squiggle's geometry (T M4.6 parity; the squiggle
// shape comes from the fragment shader, Q#W1).
let (y, h) = match bar_px {
Some(bar) => (TEXT_TOP + run.line_top + run.line_height - bar, bar),
None => (TEXT_TOP + run.line_top, run.line_height),
};
rects.push(MinimapRect {
x: text_left + x0,
y,
w: x1 - x0,
h,
color,
});
}
}
}
}
#[derive(Clone, Copy, Debug)]
struct MinimapRect {
x: f32,
y: f32,
w: f32,
h: f32,
color: [f32; 4],
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct MinimapLineShape {
indent_cols: usize,
content_cols: usize,
}
#[derive(Clone, Debug, PartialEq)]
struct RichChunk {
text: String,
color: Option<glyphon::Color>,
/// Where this chunk's text came from — the seam the pointer
/// hit-test walks back through (Q#M2).
source: ChunkSource,
}
/// Origin of one [`RichChunk`] in the shaped (projected) text.
/// Offsets are slice-relative (the same space `projected_rich_chunks`
/// works in); the hit test rebases with the slice's `vstart`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ChunkSource {
/// Verbatim source text starting at this slice byte offset.
Source { start: u64 },
/// Injected adornment text (inlay hint) anchored at this slice
/// byte offset. Hits inside it snap to the anchor.
Adornment { anchor: u64 },
}
/// One run of the projected→source hit map (Q#M2), built by
/// [`build_hit_runs`] from the same chunks `reshape` feeds glyphon —
/// so the map and the shaped buffer can never disagree.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ProjectedRun {
/// Byte offset of this run in the shaped (projected) text.
projected_start: u64,
/// Run length in projected bytes.
len: u64,
source: ChunkSource,
}
/// Build the projected→source run map plus the projected text's line
/// start table (cosmic-text reports hits as line + byte-within-line).
fn build_hit_runs(chunks: &[RichChunk]) -> (Vec<ProjectedRun>, Vec<u64>) {
let mut runs = Vec::with_capacity(chunks.len());
let mut line_starts = vec![0u64];
let mut projected = 0u64;
for chunk in chunks {
let len = chunk.text.len() as u64;
runs.push(ProjectedRun {
projected_start: projected,
len,
source: chunk.source,
});
for (i, b) in chunk.text.bytes().enumerate() {
if b == b'\n' {
line_starts.push(projected + i as u64 + 1);
}
}
projected += len;
}
(runs, line_starts)
}
/// Map a projected byte offset back to a slice-relative source byte
/// (Q#M2). Hits inside an adornment run snap to its anchor; offsets
/// past the last run clamp to its end.
fn projected_to_source(runs: &[ProjectedRun], projected: u64) -> Option<u64> {
if runs.is_empty() {
return None;
}
let idx = runs
.partition_point(|r| r.projected_start <= projected)
.saturating_sub(1);
let run = runs[idx];
let within = projected.saturating_sub(run.projected_start).min(run.len);
match run.source {
ChunkSource::Source { start } => Some(start + within),
ChunkSource::Adornment { anchor } => Some(anchor),
}
}
fn minimap_left(surface_width: u32) -> Option<f32> {
if surface_width < MINIMAP_MIN_SURFACE_WIDTH {
return None;
}
let x = surface_width as f32 - MINIMAP_RIGHT - MINIMAP_WIDTH;
(x > TEXT_LEFT + TEXT_RIGHT_GAP).then_some(x)
}
/// Where editor content stops and the status band begins (Q#S3) —
/// the single source for every bottom-of-text computation.
fn text_area_bottom(surface_height: u32) -> f32 {
(surface_height as f32 - STATUS_BAND_HEIGHT).max(0.0)
}
/// The minimap's drawable height: the text area minus its own
/// top/bottom insets.
fn minimap_height(surface_height: u32) -> f32 {
text_area_bottom(surface_height) - MINIMAP_TOP - MINIMAP_BOTTOM
}
fn estimated_visible_lines(surface_height: u32) -> usize {
((text_area_bottom(surface_height) - TEXT_TOP.max(0.0)) / CODE_LINE_HEIGHT)
.ceil()
.max(1.0) as usize
}
/// True when `(x, y)` lies inside the minimap band — the painter's
/// geometry (`minimap_left` × the `MINIMAP_TOP..bottom` column),
/// shared by the Q#M6 press hit-test.
fn minimap_band_contains(x: f32, y: f32, surface_width: u32, surface_height: u32) -> bool {
let Some(left) = minimap_left(surface_width) else {
return false;
};
let height = minimap_height(surface_height);
height > 0.0
&& x >= left
&& x < surface_width as f32 - MINIMAP_RIGHT
&& y >= MINIMAP_TOP
&& y < MINIMAP_TOP + height
}
/// The TUI mode line's scroll readout, ported verbatim (Q#S1): "All"
/// when the buffer fits, "Top"/"Bot" at the extremes, else the cursor
/// row as a percentage of the file.
fn format_scroll_indicator(
view_top: usize,
visible: usize,
total_lines: usize,
cursor_row: usize,
) -> String {
if total_lines <= 1 {
return "All".to_string();
}
if visible > 0 {
if visible >= total_lines {
return "All".to_string();
}
if view_top == 0 {
return "Top".to_string();
}
if view_top.saturating_add(visible) >= total_lines {
return "Bot".to_string();
}
}
let pct = (cursor_row + 1).saturating_mul(100) / total_lines;
format!("{pct}%")
}
/// Q#M7 — which way (if any) a drag at pixel `y` should auto-scroll:
/// `-1` in the band hugging the text area's top, `+1` in the band at
/// the text area's bottom (above the status band), `None` in the
/// interior.
fn edge_scroll_direction(y: f32, surface_height: u32) -> Option<i64> {
if y < TEXT_TOP + EDGE_SCROLL_BAND {
Some(-1)
} else if y > text_area_bottom(surface_height) - EDGE_SCROLL_BAND {
Some(1)
} else {
None
}
}
/// Map a minimap pixel `y` to a whole-file source line — the inverse
/// of the painter's `y = MINIMAP_TOP + line * height / total`
/// interpolation, clamped into the file. `None` for an empty file or
/// a degenerate surface.
fn minimap_y_to_line(y: f32, surface_height: u32, total_lines: usize) -> Option<usize> {
if total_lines == 0 {
return None;
}
let height = minimap_height(surface_height);
if height <= 0.0 {
return None;
}
let frac = ((y - MINIMAP_TOP) / height).clamp(0.0, 1.0);
Some(((frac * total_lines as f32) as usize).min(total_lines - 1))
}
fn minimap_rects(
lines: &[CellStyle],
shapes: &[MinimapLineShape],
surface_width: u32,
surface_height: u32,
first_visible_line: usize,
visible_lines: usize,
) -> Vec<MinimapRect> {
let Some(x) = minimap_left(surface_width) else {
return Vec::new();
};
if lines.is_empty() || minimap_height(surface_height) <= 0.0 {
return Vec::new();
}
let height = minimap_height(surface_height);
let pixel_rows = height.round().max(1.0) as usize;
let mut rects = Vec::new();
rects.push(MinimapRect {
x,
y: MINIMAP_TOP,
w: MINIMAP_WIDTH,
h: height,
color: MINIMAP_BG,
});
if lines.len() <= pixel_rows {
for (idx, style) in lines.iter().copied().enumerate() {
let y0 = MINIMAP_TOP + idx as f32 * height / lines.len() as f32;
let y1 = MINIMAP_TOP + (idx + 1) as f32 * height / lines.len() as f32;
if let Some(shape) = shapes
.get(idx)
.copied()
.filter(MinimapLineShape::has_content)
{
push_minimap_line_stroke(
&mut rects,
x,
y0,
(y1 - y0).clamp(1.0, MINIMAP_MAX_LINE_STROKE_HEIGHT),
minimap_style_color(style),
shape,
);
}
}
} else {
for row in 0..pixel_rows {
let line_start = row * lines.len() / pixel_rows;
let line_end = ((row + 1) * lines.len())
.div_ceil(pixel_rows)
.min(lines.len());
let y0 = MINIMAP_TOP + row as f32 * height / pixel_rows as f32;
let y1 = MINIMAP_TOP + (row + 1) as f32 * height / pixel_rows as f32;
if let Some(shape) = dominant_line_shape(shapes, line_start, line_end) {
push_minimap_line_stroke(
&mut rects,
x,
y0,
(y1 - y0).max(1.0),
minimap_style_color(dominant_line_style(&lines[line_start..line_end])),
shape,
);
}
}
}
push_minimap_thumb(
&mut rects,
x,
height,
lines.len(),
first_visible_line,
visible_lines,
);
rects
}
impl MinimapLineShape {
fn has_content(&self) -> bool {
self.content_cols > 0
}
}
fn push_minimap_line_stroke(
rects: &mut Vec<MinimapRect>,
x: f32,
y: f32,
h: f32,
color: [f32; 4],
shape: MinimapLineShape,
) {
if !shape.has_content() {
return;
}
let available = (MINIMAP_WIDTH - MINIMAP_H_PAD * 2.0).max(MINIMAP_MIN_STROKE_WIDTH);
let indent = (shape.indent_cols as f32 / MINIMAP_CODE_COLS * available)
.min((available - MINIMAP_MIN_STROKE_WIDTH).max(0.0));
let width = (shape.content_cols as f32 / MINIMAP_CODE_COLS * available).clamp(
MINIMAP_MIN_STROKE_WIDTH,
(available - indent).max(MINIMAP_MIN_STROKE_WIDTH),
);
rects.push(MinimapRect {
x: x + MINIMAP_H_PAD + indent,
y,
w: width,
h,
color,
});
}
fn push_minimap_thumb(
rects: &mut Vec<MinimapRect>,
x: f32,
minimap_height: f32,
line_count: usize,
first_visible_line: usize,
visible_lines: usize,
) {
let start_line = first_visible_line.min(line_count);
let end_line = start_line
.saturating_add(visible_lines.max(1))
.min(line_count);
let mut y0 = MINIMAP_TOP + start_line as f32 * minimap_height / line_count as f32;
let mut y1 = MINIMAP_TOP + end_line as f32 * minimap_height / line_count as f32;
if y1 - y0 < MINIMAP_MIN_THUMB_HEIGHT {
let mid = (y0 + y1) * 0.5;
y0 = (mid - MINIMAP_MIN_THUMB_HEIGHT * 0.5).max(MINIMAP_TOP);
y1 = (y0 + MINIMAP_MIN_THUMB_HEIGHT).min(MINIMAP_TOP + minimap_height);
y0 = (y1 - MINIMAP_MIN_THUMB_HEIGHT).max(MINIMAP_TOP);
}
let h = (y1 - y0).max(1.0);
rects.push(MinimapRect {
x,
y: y0,
w: MINIMAP_WIDTH,
h,
color: MINIMAP_THUMB_FILL,
});
rects.push(MinimapRect {
x,
y: y0,
w: 1.0,
h,
color: MINIMAP_THUMB_BORDER,
});
rects.push(MinimapRect {
x: x + MINIMAP_WIDTH - 1.0,
y: y0,
w: 1.0,
h,
color: MINIMAP_THUMB_BORDER,
});
}
fn dominant_line_style(lines: &[CellStyle]) -> CellStyle {
if lines.is_empty() {
return CellStyle::default();
}
let mut tally: Vec<(CellStyle, usize)> = Vec::new();
for style in lines {
if let Some((_, count)) = tally.iter_mut().find(|(candidate, _)| candidate == style) {
*count += 1;
} else {
tally.push((*style, 1));
}
}
tally
.into_iter()
.max_by_key(|(_, count)| *count)
.map_or(CellStyle::default(), |(style, _)| style)
}
fn dominant_line_shape(
shapes: &[MinimapLineShape],
line_start: usize,
line_end: usize,
) -> Option<MinimapLineShape> {
let slice = shapes.get(line_start.min(shapes.len())..line_end.min(shapes.len()))?;
let mut count = 0usize;
let mut indent_sum = 0usize;
let mut content_sum = 0usize;
for shape in slice.iter().filter(|shape| shape.has_content()) {
count += 1;
indent_sum += shape.indent_cols;
content_sum += shape.content_cols;
}
(count > 0).then_some(MinimapLineShape {
indent_cols: indent_sum / count,
content_cols: content_sum.div_ceil(count),
})
}
fn minimap_line_shapes(text: &str) -> Vec<MinimapLineShape> {
text.split('\n').map(minimap_line_shape).collect()
}
fn minimap_line_shape(line: &str) -> MinimapLineShape {
let mut total_cols = 0usize;
let mut indent_cols = 0usize;
let mut in_indent = true;
for ch in line.trim_end_matches('\r').chars() {
let next_col = advance_minimap_col(total_cols, ch);
if in_indent && (ch == ' ' || ch == '\t') {
indent_cols = next_col;
} else {
in_indent = false;
}
total_cols = next_col;
}
MinimapLineShape {
indent_cols,
content_cols: total_cols.saturating_sub(indent_cols),
}
}
fn advance_minimap_col(col: usize, ch: char) -> usize {
if ch == '\t' {
((col / 4) + 1) * 4
} else {
col + 1
}
}
fn minimap_style_color(style: CellStyle) -> [f32; 4] {
// A set underline_color is the producer's diagnostic mark for the
// line (protocol v6, T M4.6 parity) — the minimap's gutter sign.
// It outranks the syntax-dominant fg so error/warning lines read
// at a glance.
let color = match style.underline_color {
CellColor::Default => style.fg,
marked => marked,
};
match color {
CellColor::Default => MINIMAP_DEFAULT_LINE,
CellColor::Rgb(r, g, b) => rgb_to_minimap_color(r, g, b),
CellColor::Indexed(idx) => {
let c = indexed_to_glyphon(idx);
rgb_to_minimap_color(c.r(), c.g(), c.b())
}
}
}
fn rgb_to_minimap_color(r: u8, g: u8, b: u8) -> [f32; 4] {
[
f32::from(r) / 255.0,
f32::from(g) / 255.0,
f32::from(b) / 255.0,
0.9,
]
}
/// One-shot env flag: `PMACS_GPU_DEBUG_PRESENCE=1` logs each received
/// `PresenceUpdate`. Read once (the env lock is not free per call) and
/// cached for the process lifetime.
fn debug_presence() -> bool {
static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_PRESENCE").is_some())
}
/// One-shot env flag: `PMACS_GPU_DEBUG_FRAME=1` logs per-`render()`
/// sub-phase timings (background rects, minimap rects, glyph prepare,
/// total) so a perceived cursor-tracking slowdown can be localized to
/// a specific phase.
fn debug_frame() -> bool {
static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_FRAME").is_some())
}
/// One-shot env flag: `PMACS_GPU_DEBUG_APPLY=1` logs how long the
/// main thread spends applying each inbound daemon message. This
/// separates CRDT text patching, style replacement, and cursor updates
/// from the later `render()` timings.
fn debug_apply() -> bool {
static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_APPLY").is_some())
}
fn instance_message_label(msg: &InstanceMessage) -> &'static str {
match msg {
InstanceMessage::CellDelta { .. } => "CellDelta",
InstanceMessage::Cursor(_) => "Cursor",
InstanceMessage::ModeLine(_) => "ModeLine",
InstanceMessage::Signal(_) => "Signal",
InstanceMessage::Goodbye(_) => "Goodbye",
InstanceMessage::CrdtOp { .. } => "CrdtOp",
InstanceMessage::PresenceUpdate { .. } => "PresenceUpdate",
InstanceMessage::BufferSnapshot { .. } => "BufferSnapshot",
InstanceMessage::CursorByte { .. } => "CursorByte",
InstanceMessage::StyleSpans { .. } => "StyleSpans",
InstanceMessage::Decorations { .. } => "Decorations",
InstanceMessage::InlineAdornments { .. } => "InlineAdornments",
InstanceMessage::FileStyleSummary { .. } => "FileStyleSummary",
InstanceMessage::StatusFacts { .. } => "StatusFacts",
InstanceMessage::SearchPrompt { .. } => "SearchPrompt",
InstanceMessage::MenuPrompt { .. } => "MenuPrompt",
InstanceMessage::MinibufferPrompt { .. } => "MinibufferPrompt",
InstanceMessage::BlockAdornments { .. } => "BlockAdornments",
InstanceMessage::FoldState { .. } => "FoldState",
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
InstanceMessage::DispatchIdle { .. } => "DispatchIdle",
InstanceMessage::LineNumbers { .. } => "LineNumbers",
InstanceMessage::CompletionPopup { .. } => "CompletionPopup",
InstanceMessage::ThemeFacts { .. } => "ThemeFacts",
}
}
/// One-shot env flag: `PMACS_GPU_DEBUG_INPUT=1` logs the input path —
/// keys sent and `CursorByte` received (with the buffer it targets vs
/// the buffer being displayed). The buffer comparison is the B1
/// diagnostic: if `CursorByte` targets a different buffer than
/// `current`, the caret won't track (the displayed/edited buffers are
/// out of sync).
fn debug_input() -> bool {
static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_INPUT").is_some())
}
/// Translate a winit logical key + current modifier state into a
/// protocol `(Key, Modifiers)`. Returns `None` for keys the protocol
/// has no representation for (the daemon ignores `Key::Unknown`, so
/// there's no value in forwarding them). `translate_key` covers the
/// full editing set; session B1 gates the send on [`is_motion_key`].
fn translate_mods(mods: winit::keyboard::ModifiersState) -> Modifiers {
let mut bits = 0u8;
if mods.shift_key() {
bits |= Modifiers::SHIFT.bits();
}
if mods.control_key() {
bits |= Modifiers::CTRL.bits();
}
if mods.alt_key() {
bits |= Modifiers::ALT.bits();
}
if mods.super_key() {
bits |= Modifiers::META.bits();
}
Modifiers::from_bits_truncate(bits)
}
fn translate_key(
logical: &Key,
mods: winit::keyboard::ModifiersState,
) -> Option<(ProtocolKey, Modifiers)> {
let pmods = translate_mods(mods);
let pkey = match logical {
Key::Named(named) => match named {
NamedKey::ArrowLeft => ProtocolKey::Left,
NamedKey::ArrowRight => ProtocolKey::Right,
NamedKey::ArrowUp => ProtocolKey::Up,
NamedKey::ArrowDown => ProtocolKey::Down,
NamedKey::Home => ProtocolKey::Home,
NamedKey::End => ProtocolKey::End,
NamedKey::PageUp => ProtocolKey::PageUp,
NamedKey::PageDown => ProtocolKey::PageDown,
NamedKey::Backspace => ProtocolKey::Backspace,
NamedKey::Enter => ProtocolKey::Enter,
NamedKey::Delete => ProtocolKey::Delete,
NamedKey::Insert => ProtocolKey::Insert,
NamedKey::Tab => ProtocolKey::Tab,
NamedKey::Space => ProtocolKey::Char(' '),
_ => return None,
},
Key::Character(s) => ProtocolKey::Char(s.chars().next()?),
_ => return None,
};
Some((pkey, pmods))
}
/// Cursor-motion keys — forwarded with any modifier set (e.g. `C-Left`
/// is word-motion, `S-Down` extends a selection; the daemon's keymap
/// decides).
fn is_motion_key(key: ProtocolKey) -> bool {
matches!(
key,
ProtocolKey::Left
| ProtocolKey::Right
| ProtocolKey::Up
| ProtocolKey::Down
| ProtocolKey::Home
| ProtocolKey::End
| ProtocolKey::PageUp
| ProtocolKey::PageDown
)
}
/// Whether to forward a translated key to the daemon (session B2).
/// Motion keys go through with any modifiers (C-<left> is word
/// motion). Deletion keys do too: C-BS / C-DEL / M-BS are word-level
/// deletes in the default keymap — the same editing-command family as
/// chorded motion, and an unbound chord is a harmless no-op at the
/// daemon keymap. (Chorded deletes never apply optimistically:
/// `optimistic_delete_range` requires empty modifiers, so they always
/// round-trip into their bound commands.) The remaining text keys
/// (`Char` / `Enter` / `Tab`) go through only *without* a
/// Ctrl/Alt/Meta chord modifier: a bare key edits text, but those
/// chords drive commands and minibuffer flows the GUI can't render or
/// interact with yet (deferred to a later session). Shift is not a
/// chord modifier — `Shift`+a already arrives as `Char('A')`.
fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool {
if is_motion_key(key) {
return true;
}
if matches!(key, ProtocolKey::Backspace | ProtocolKey::Delete) {
return true;
}
if !is_plain_text_modifiers(mods) {
return false;
}
matches!(
key,
ProtocolKey::Char(_) | ProtocolKey::Enter | ProtocolKey::Tab
)
}
/// A command chord (Q#GC1): a `Char` / `Enter` / `Tab` with `Ctrl` or
/// `Alt` held. These drive the daemon keymap (motion like `C-a`,
/// commands like `M-f` / `C-x C-s`, isearch `C-s`, clipboard `M-w`,
/// `M-x`, …) and are forwarded to it, subsuming the old per-feature
/// allowlists. `Char + Ctrl/Alt` is the exact set `should_forward_key`
/// withholds; motion / `Backspace` / `Delete` keep their own path, and
/// `Meta`/`Super`-only chords (no `Ctrl`/`Alt`) are left to the OS.
/// `Ctrl-V` is intercepted for OS paste before this is reached.
fn is_command_chord(key: ProtocolKey, mods: Modifiers) -> bool {
matches!(
key,
ProtocolKey::Char(_) | ProtocolKey::Enter | ProtocolKey::Tab
) && (mods.contains(Modifiers::CTRL) || mods.contains(Modifiers::ALT))
}
/// Whether a keypress is **`AltGr` layout text** (audit F-004): winit
/// produced printable `text` while **both `Ctrl` and `Alt`** are held.
/// `AltGr` is `Ctrl+Alt` on Windows (the OS synthesizes LCtrl+RAlt), and
/// on such layouts the produced character (`@`, `€`, `{`, …) would
/// otherwise be misclassified as a command chord; when this is true the
/// caller strips the command modifiers so it inserts.
///
/// The gate is deliberately `Ctrl+Alt`, **not** "any command modifier":
/// `Alt` alone is *not* `AltGr`. On macOS the `Option` key is reported as
/// `Alt` and produces printable text for most letters (`Option+x` → "≈"),
/// but Option-as-Meta is exactly how the GUI reaches `M-x` / `M-f` / … —
/// stripping `Alt`-alone would swallow every macOS Meta chord. Genuine
/// `AltGr` needs both modifiers, so requiring both leaves `Alt`-alone
/// (macOS `Option`, plain `Meta`) to forward as command chords. Returns
/// `false` for genuine command chords (no text, or a control char) and
/// for plain text (no command modifier — already handled).
fn is_layout_text(text: Option<&str>, mods: Modifiers) -> bool {
mods.contains(Modifiers::CTRL)
&& mods.contains(Modifiers::ALT)
&& text.is_some_and(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()))
}
fn is_plain_text_modifiers(mods: Modifiers) -> bool {
!mods.contains(Modifiers::CTRL)
&& !mods.contains(Modifiers::ALT)
&& !mods.contains(Modifiers::META)
&& !mods.contains(Modifiers::HYPER)
}
/// Clip a whole-file byte range `[start, end)` to the visible slice
/// `[vstart, vend)` and rebase it into slice coordinates (subtract
/// `vstart`). Returns `None` when the range is disjoint from the slice.
/// The single rebasing primitive for session S1 — caret and washes
/// route through it (Q#S4).
fn clip_rebase_range(start: u64, end: u64, vstart: u64, vend: u64) -> Option<(u64, u64)> {
let s = start.max(vstart);
let e = end.min(vend);
if e <= s {
return None;
}
Some((s - vstart, e - vstart))
}
/// Sum of the doc's per-peer version-vector counters — the **same
/// formula** as the daemon's `CrdtState::version_scalar`, which is
/// what the producer stamps into `StyleSpans` / `Decorations`
/// `generation`. The sum is integration-order independent, so once
/// both replicas hold the same set of ops the scalars are equal;
/// that is what makes frame generations comparable against locally
/// computed values in `unconfirmed_edits`.
fn loro_version_scalar(doc: &loro::LoroDoc) -> u64 {
doc.oplog_vv()
.values()
.map(|counter| u64::try_from(*counter).unwrap_or(0))
.sum()
}
/// Translate one incoming `StyleSpans` frame's segments through the
/// optimistic edits the daemon had not yet integrated when it
/// computed the frame. Ranges that a (defensive) delete fully
/// removes drop out.
fn translate_style_segments(
segments: Vec<StyleSegment>,
edits: &[(u64, TextProjectionEdit)],
) -> Vec<StyleSegment> {
if edits.is_empty() {
return segments;
}
segments
.into_iter()
.filter_map(|seg| {
let mut range = seg.range;
let mut spans = seg.spans;
for (_, edit) in edits {
range = translate_byte_range(range, *edit)?;
spans = spans
.into_iter()
.filter_map(|mut sp| {
sp.range = translate_byte_range(sp.range, *edit)?;
Some(sp)
})
.collect();
}
Some(StyleSegment { range, spans })
})
.collect()
}
/// `Decorations` twin of [`translate_style_segments`].
fn translate_decoration_segments(
segments: Vec<DecorationSegment>,
edits: &[(u64, TextProjectionEdit)],
) -> Vec<DecorationSegment> {
if edits.is_empty() {
return segments;
}
segments
.into_iter()
.filter_map(|seg| {
let mut range = seg.range;
let mut decorations = seg.decorations;
for (_, edit) in edits {
range = translate_byte_range(range, *edit)?;
decorations = decorations
.into_iter()
.filter_map(|mut d| {
d.range = translate_byte_range(d.range, *edit)?;
Some(d)
})
.collect();
}
Some(DecorationSegment { range, decorations })
})
.collect()
}
fn subscribe_loro_text(doc: &loro::LoroDoc) -> (LoroTextDeltaBatches, loro::Subscription) {
let text = doc.get_text(LORO_TEXT_CONTAINER);
let delta_batches = Arc::new(Mutex::new(Vec::<Vec<loro::TextDelta>>::new()));
let captured_batches = Arc::clone(&delta_batches);
let subscription = doc.subscribe(
&text.id(),
Arc::new(move |event| {
let mut guard = captured_batches
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for event in event.events {
if let Some(delta) = event.diff.as_text()
&& !delta.is_empty()
{
guard.push(delta.clone());
}
}
}),
);
(delta_batches, subscription)
}
fn clear_loro_text_delta_batches(delta_batches: &LoroTextDeltaBatches) {
delta_batches
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
}
fn drain_loro_text_delta_batches(
delta_batches: &LoroTextDeltaBatches,
) -> Vec<Vec<loro::TextDelta>> {
let mut guard = delta_batches
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::mem::take(&mut *guard)
}
/// Largest char-boundary `<= index` (stable equivalent of the unstable
/// `str::floor_char_boundary`). Used to snap externally-supplied byte
/// offsets to valid slice points so a stale, mid-codepoint offset can't
/// panic a `text[..]` slice.
fn floor_char_boundary(text: &str, index: usize) -> usize {
if index >= text.len() {
return text.len();
}
let mut i = index;
while i > 0 && !text.is_char_boundary(i) {
i -= 1;
}
i
}
/// Buffer-absolute byte offset of the start of each `\n`-delimited
/// line (index 0 = byte 0). Indexed by cosmic-text's
/// `LayoutRun::line_i` to rebase line-relative glyph offsets.
fn line_byte_offsets(text: &str) -> Vec<u64> {
line_offset_tables(text).0
}
fn line_offset_tables(text: &str) -> (Vec<u64>, Vec<u64>) {
let mut starts = vec![0u64];
let mut char_starts = vec![0u64];
let mut chars_seen = 0u64;
for (byte, ch) in text.char_indices() {
chars_seen += 1;
if ch == '\n' {
starts.push(byte as u64 + 1);
char_starts.push(chars_seen);
}
}
(starts, char_starts)
}
fn byte_offset_for_char_offset(
text: &str,
line_starts: &[u64],
line_char_starts: &[u64],
char_offset: usize,
) -> Option<usize> {
if line_starts.len() != line_char_starts.len() {
return None;
}
let line = line_char_starts
.partition_point(|&start| start <= char_offset as u64)
.saturating_sub(1);
let byte_start = *line_starts.get(line)? as usize;
let char_start = *line_char_starts.get(line)? as usize;
let mut byte = byte_start;
for _ in 0..char_offset.checked_sub(char_start)? {
let ch = text.get(byte..)?.chars().next()?;
byte += ch.len_utf8();
}
Some(byte)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct TextProjectionEdit {
start: u64,
old_end: u64,
inserted_len: u64,
}
fn apply_loro_text_delta_batches(
text: &mut String,
line_starts: &mut Vec<u64>,
line_char_starts: &mut Vec<u64>,
delta_batches: &[Vec<loro::TextDelta>],
) -> Result<Vec<TextProjectionEdit>, &'static str> {
let mut edits = Vec::new();
for delta in delta_batches {
apply_loro_text_delta_batch(text, line_starts, line_char_starts, delta, &mut edits)?;
}
Ok(edits)
}
fn apply_loro_text_delta_batch(
text: &mut String,
line_starts: &mut Vec<u64>,
line_char_starts: &mut Vec<u64>,
delta: &[loro::TextDelta],
edits: &mut Vec<TextProjectionEdit>,
) -> Result<(), &'static str> {
let mut cursor_char = 0usize;
for op in delta {
match op {
loro::TextDelta::Retain { retain, .. } => {
cursor_char = cursor_char
.checked_add(*retain)
.ok_or("retain offset overflow")?;
}
loro::TextDelta::Insert { insert, .. } => {
if insert.is_empty() {
continue;
}
let start_byte =
byte_offset_for_char_offset(text, line_starts, line_char_starts, cursor_char)
.ok_or("insert offset outside current text")?;
replace_text_range_with_line_updates(
text,
line_starts,
line_char_starts,
start_byte,
start_byte,
cursor_char,
cursor_char,
insert,
)?;
edits.push(TextProjectionEdit {
start: start_byte as u64,
old_end: start_byte as u64,
inserted_len: insert.len() as u64,
});
cursor_char = cursor_char
.checked_add(insert.chars().count())
.ok_or("insert offset overflow")?;
}
loro::TextDelta::Delete { delete } => {
if *delete == 0 {
continue;
}
let start_char = cursor_char;
let end_char = cursor_char
.checked_add(*delete)
.ok_or("delete offset overflow")?;
let start_byte =
byte_offset_for_char_offset(text, line_starts, line_char_starts, start_char)
.ok_or("delete start outside current text")?;
let end_byte =
byte_offset_for_char_offset(text, line_starts, line_char_starts, end_char)
.ok_or("delete end outside current text")?;
replace_text_range_with_line_updates(
text,
line_starts,
line_char_starts,
start_byte,
end_byte,
start_char,
end_char,
"",
)?;
edits.push(TextProjectionEdit {
start: start_byte as u64,
old_end: end_byte as u64,
inserted_len: 0,
});
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn replace_text_range_with_line_updates(
text: &mut String,
line_starts: &mut Vec<u64>,
line_char_starts: &mut Vec<u64>,
start_byte: usize,
end_byte: usize,
start_char: usize,
end_char: usize,
insert: &str,
) -> Result<(), &'static str> {
if line_starts.len() != line_char_starts.len() {
return Err("line offset tables have different lengths");
}
if start_byte > end_byte || end_byte > text.len() {
return Err("replacement byte range is outside current text");
}
if start_char > end_char {
return Err("replacement char range is inverted");
}
if !text.is_char_boundary(start_byte) || !text.is_char_boundary(end_byte) {
return Err("replacement byte range is not on char boundaries");
}
let start_line = line_starts
.partition_point(|&start| start <= start_byte as u64)
.saturating_sub(1);
let remove_start = start_line + 1;
let remove_end = line_starts.partition_point(|&start| start <= end_byte as u64);
let (inserted_line_starts, inserted_line_char_starts) =
inserted_line_offsets(insert, start_byte, start_char);
let inserted_line_count = inserted_line_starts.len();
let byte_delta = signed_usize_delta(insert.len(), end_byte - start_byte)?;
let char_delta = signed_usize_delta(insert.chars().count(), end_char - start_char)?;
text.replace_range(start_byte..end_byte, insert);
line_starts.splice(remove_start..remove_end, inserted_line_starts);
line_char_starts.splice(remove_start..remove_end, inserted_line_char_starts);
let suffix_start = remove_start + inserted_line_count;
for start in line_starts.iter_mut().skip(suffix_start) {
shift_u64(start, byte_delta);
}
for start in line_char_starts.iter_mut().skip(suffix_start) {
shift_u64(start, char_delta);
}
Ok(())
}
fn inserted_line_offsets(
insert: &str,
start_byte: usize,
start_char: usize,
) -> (Vec<u64>, Vec<u64>) {
let mut line_starts = Vec::new();
let mut line_char_starts = Vec::new();
let mut chars_seen = 0usize;
for (rel_byte, ch) in insert.char_indices() {
chars_seen += 1;
if ch == '\n' {
line_starts.push((start_byte + rel_byte + 1) as u64);
line_char_starts.push((start_char + chars_seen) as u64);
}
}
(line_starts, line_char_starts)
}
fn shift_u64(value: &mut u64, delta: i64) {
if delta >= 0 {
*value = value.saturating_add(delta as u64);
} else {
*value = value.saturating_sub(delta.unsigned_abs());
}
}
fn signed_usize_delta(new_len: usize, old_len: usize) -> Result<i64, &'static str> {
let new_len = i64::try_from(new_len).map_err(|_| "new length exceeds i64")?;
let old_len = i64::try_from(old_len).map_err(|_| "old length exceeds i64")?;
Ok(new_len - old_len)
}
fn translate_style_spans(spans: &mut Vec<StyleSpan>, edit: TextProjectionEdit) {
let mut translated = Vec::with_capacity(spans.len());
for mut span in spans.drain(..) {
if let Some(range) = translate_byte_range(span.range, edit) {
span.range = range;
translated.push(span);
}
}
*spans = translated;
}
fn translate_decorations(decorations: &mut Vec<Decoration>, edit: TextProjectionEdit) {
let mut translated = Vec::with_capacity(decorations.len());
for mut decoration in decorations.drain(..) {
// Optimistic clear (diagnostics only): an edit that touches a
// diagnostic's range invalidates it locally, so drop the
// squiggle now instead of holding a stale wave over the text
// you just changed until the LSP re-analyzes and republishes.
// Scoped to the *touched* diagnostic — an error elsewhere
// still translates and holds, so the no-blink benefit of the
// producer's hold-while-stale survives. Non-diagnostic
// decorations (selection / current-line) always translate.
if decoration_kind_to_underline_color(decoration.kind).is_some()
&& edit_touches_range(decoration.range, edit)
{
continue;
}
if let Some(range) = translate_byte_range(decoration.range, edit) {
decoration.range = range;
translated.push(decoration);
}
}
*decorations = translated;
}
/// True when `edit` touches `range` in the pre-edit coordinate space.
/// An insert (`old_end == start`) touches when its point lies within
/// `[start, end]` (inclusive — typing at either edge of an error
/// token counts); a delete/replace touches when its span overlaps.
fn edit_touches_range(range: ByteRange, edit: TextProjectionEdit) -> bool {
edit.start <= range.end && range.start <= edit.old_end
}
fn translate_inline_adornments(adornments: &mut [InlineAdornment], edit: TextProjectionEdit) {
for adornment in adornments {
adornment.at = translate_byte_position(adornment.at, edit);
}
}
fn translate_byte_range(range: ByteRange, edit: TextProjectionEdit) -> Option<ByteRange> {
let start = translate_range_start(range.start, edit);
let end = translate_range_end(range.end, edit);
(start < end).then_some(ByteRange { start, end })
}
fn translate_range_start(pos: u64, edit: TextProjectionEdit) -> u64 {
if edit.old_end == edit.start {
if pos >= edit.start {
pos.saturating_add(edit.inserted_len)
} else {
pos
}
} else if pos <= edit.start {
pos
} else if pos >= edit.old_end {
shift_position(pos, edit)
} else {
edit.start
}
}
fn translate_range_end(pos: u64, edit: TextProjectionEdit) -> u64 {
if edit.old_end == edit.start {
// `>=` (not `>`): a range ending exactly at a pure-insert
// point *extends over* the inserted text. Typing at the end
// of a token is the dominant editing case, and inheriting the
// preceding span's color keeps the new char stably colored
// instead of blinking default-white until the next parse
// settles. (The start counterpart keeps `>=` shifting right,
// so a following span never overlaps the extension.)
if pos >= edit.start {
pos.saturating_add(edit.inserted_len)
} else {
pos
}
} else if pos <= edit.start {
pos
} else if pos >= edit.old_end {
shift_position(pos, edit)
} else {
edit.start.saturating_add(edit.inserted_len)
}
}
fn translate_byte_position(pos: u64, edit: TextProjectionEdit) -> u64 {
if edit.old_end == edit.start {
if pos >= edit.start {
pos.saturating_add(edit.inserted_len)
} else {
pos
}
} else if pos <= edit.start {
pos
} else if pos >= edit.old_end {
shift_position(pos, edit)
} else {
edit.start.saturating_add(edit.inserted_len)
}
}
fn shift_position(pos: u64, edit: TextProjectionEdit) -> u64 {
let old_len = edit.old_end.saturating_sub(edit.start);
if edit.inserted_len >= old_len {
pos.saturating_add(edit.inserted_len - old_len)
} else {
pos.saturating_sub(old_len - edit.inserted_len)
}
}
/// Byte range `[start, end)` of the source line containing `cursor`:
/// `start` is just after the previous `\n` (or 0), `end` is just after
/// the next `\n` (or text length). Mirrors the producer's
/// `current_line_range` so the rendered `CurrentLine` wash covers the
/// same bytes the producer would. `cursor` is clamped to the text
/// length so a peer presence that briefly lags an edit is safe.
fn source_line_range(text: &str, cursor: u64) -> (u64, u64) {
let c = (cursor as usize).min(text.len());
let start = text[..c].rfind('\n').map_or(0, |i| i + 1);
let end = text[c..].find('\n').map_or(text.len(), |i| c + i + 1);
(start as u64, end as u64)
}
fn rects_to_vertex_bytes(
rects: &[MinimapRect],
surface_width: u32,
surface_height: u32,
) -> Vec<u8> {
let mut bytes = Vec::with_capacity(rects.len() * 6 * QUAD_VERTEX_STRIDE as usize);
for rect in rects {
push_rect_vertices(&mut bytes, *rect, surface_width, surface_height);
}
bytes
}
fn push_rect_vertices(bytes: &mut Vec<u8>, rect: MinimapRect, width: u32, height: u32) {
if rect.w <= 0.0 || rect.h <= 0.0 || width == 0 || height == 0 {
return;
}
let x0 = px_to_ndc_x(rect.x, width);
let x1 = px_to_ndc_x(rect.x + rect.w, width);
let y0 = px_to_ndc_y(rect.y, height);
let y1 = px_to_ndc_y(rect.y + rect.h, height);
push_quad_vertex(bytes, x0, y0, rect.color);
push_quad_vertex(bytes, x1, y0, rect.color);
push_quad_vertex(bytes, x1, y1, rect.color);
push_quad_vertex(bytes, x0, y0, rect.color);
push_quad_vertex(bytes, x1, y1, rect.color);
push_quad_vertex(bytes, x0, y1, rect.color);
}
fn push_quad_vertex(bytes: &mut Vec<u8>, x: f32, y: f32, color: [f32; 4]) {
for value in [x, y, color[0], color[1], color[2], color[3]] {
bytes.extend_from_slice(&value.to_ne_bytes());
}
}
/// Vertex bytes for the diagnostic-squiggle pipeline (Q#W1). Same six
/// vertices per rect as a quad, but each carries a `uv`: `uv.x` is the
/// absolute screen-space pixel x of the corner (continuous phase
/// across separately emitted rects), `uv.y` is the signed pixel offset
/// from the band's vertical centerline (`±h/2`). The fragment shader
/// turns that into the sine.
fn squiggles_to_vertex_bytes(rects: &[MinimapRect], width: u32, height: u32) -> Vec<u8> {
let mut bytes = Vec::with_capacity(rects.len() * 6 * SQUIGGLE_VERTEX_STRIDE as usize);
for rect in rects {
if rect.w <= 0.0 || rect.h <= 0.0 || width == 0 || height == 0 {
continue;
}
let x0 = px_to_ndc_x(rect.x, width);
let x1 = px_to_ndc_x(rect.x + rect.w, width);
let y0 = px_to_ndc_y(rect.y, height);
let y1 = px_to_ndc_y(rect.y + rect.h, height);
let (left, right) = (rect.x, rect.x + rect.w);
let half = rect.h * 0.5;
// Top edge → centerline-relative uv.y = -half; bottom → +half.
push_squiggle_vertex(&mut bytes, x0, y0, left, -half, rect.color);
push_squiggle_vertex(&mut bytes, x1, y0, right, -half, rect.color);
push_squiggle_vertex(&mut bytes, x1, y1, right, half, rect.color);
push_squiggle_vertex(&mut bytes, x0, y0, left, -half, rect.color);
push_squiggle_vertex(&mut bytes, x1, y1, right, half, rect.color);
push_squiggle_vertex(&mut bytes, x0, y1, left, half, rect.color);
}
bytes
}
fn push_squiggle_vertex(
bytes: &mut Vec<u8>,
x: f32,
y: f32,
uv_x: f32,
uv_y: f32,
color: [f32; 4],
) {
for value in [x, y, uv_x, uv_y, color[0], color[1], color[2], color[3]] {
bytes.extend_from_slice(&value.to_ne_bytes());
}
}
fn px_to_ndc_x(x: f32, width: u32) -> f32 {
x / width as f32 * 2.0 - 1.0
}
fn px_to_ndc_y(y: f32, height: u32) -> f32 {
1.0 - y / height as f32 * 2.0
}
/// Build the rich-text chunks fed to glyphon. Source chunks come from
/// `text` and retain source-byte styling; inline adornments create
/// extra chunks at their anchors and therefore do not shift any source
/// span/decoration range.
/// Clip + rebase the whole-file styling caches onto the byte range
/// `[start, end)` and build its projected chunks (Q#R1: the ONE chunk
/// source both the full `reshape` and the per-line surgery derive
/// from, so the two paths cannot disagree about a line's content).
/// Adornment anchors use an inclusive end — an anchor exactly at
/// `end` (a line's `\n`, or the slice end) injects after the last
/// content byte, matching the full-walk boundary behavior.
/// Assemble one shaped line from its chunks: concatenated projected
/// text + an attrs span per colored chunk (mirroring `set_rich_text`'s
/// only-when-non-default rule). Every line gets `LineEnding::Lf` —
/// the separator byte itself never enters a line's text.
fn line_from_chunks(chunks: &[RichChunk]) -> glyphon::cosmic_text::BufferLine {
let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
let mut attrs_list = glyphon::cosmic_text::AttrsList::new(&default_attrs);
let mut text = String::new();
for chunk in chunks {
let start = text.len();
text.push_str(&chunk.text);
if let Some(c) = chunk.color {
attrs_list.add_span(start..text.len(), &default_attrs.clone().color(c));
}
}
glyphon::cosmic_text::BufferLine::new(
text,
glyphon::cosmic_text::LineEnding::Lf,
attrs_list,
Shaping::Advanced,
)
}
fn clipped_chunks_for_range(
text: &str,
spans: &[StyleSpan],
adornments: &[InlineAdornment],
start: u64,
end: u64,
) -> Vec<RichChunk> {
let range_text = &text[start as usize..end as usize];
let spans: Vec<StyleSpan> = spans
.iter()
.filter_map(|sp| {
clip_rebase_range(sp.range.start, sp.range.end, start, end).map(|(s, e)| StyleSpan {
range: ByteRange { start: s, end: e },
style: sp.style,
})
})
.collect();
let adornments: Vec<InlineAdornment> = adornments
.iter()
.filter(|a| a.at >= start && a.at <= end)
.map(|a| {
let mut a = a.clone();
a.at -= start;
a
})
.collect();
projected_rich_chunks(range_text, &spans, &adornments)
}
fn projected_rich_chunks(
text: &str,
spans: &[StyleSpan],
adornments: &[InlineAdornment],
) -> Vec<RichChunk> {
let text_len = text.len() as u64;
// Every boundary used to slice `text` must be snapped to a UTF-8
// char boundary. Span / decoration / adornment offsets come from
// the daemon for a possibly-earlier generation than the rope this
// frame holds (the one-frame edit race), so a raw offset can land
// inside a multi-byte char and panic the slice. Flooring to the
// previous char boundary is safe: it only shifts a chunk edge left
// to the start of the codepoint it fell inside.
let snap = |b: u64| floor_char_boundary(text, b.min(text_len) as usize) as u64;
let mut boundaries: Vec<u64> = vec![0, text_len];
for sp in spans {
boundaries.push(snap(sp.range.start));
boundaries.push(snap(sp.range.end));
}
let mut renderable_adornments: Vec<(usize, u64, &InlineAdornment)> = adornments
.iter()
.enumerate()
.filter_map(|(idx, a)| renderable_adornment_anchor(a, text_len).map(|at| (idx, at, a)))
.collect();
for (_, at, _) in &renderable_adornments {
boundaries.push(snap(*at));
}
boundaries.sort_unstable();
boundaries.dedup();
renderable_adornments.sort_by_key(|(idx, at, _)| (*at, *idx));
let mut chunks = Vec::new();
let mut adorn_idx = 0usize;
for w in boundaries.windows(2) {
let (a, b) = (w[0], w[1]);
push_adornments_at(&mut chunks, &renderable_adornments, &mut adorn_idx, a);
if a < b {
chunks.push(RichChunk {
text: text[a as usize..b as usize].to_owned(),
color: source_color_at(a, spans),
source: ChunkSource::Source { start: a },
});
}
}
push_adornments_at(
&mut chunks,
&renderable_adornments,
&mut adorn_idx,
text_len,
);
if chunks.is_empty() {
chunks.push(RichChunk {
text: String::new(),
color: None,
source: ChunkSource::Source { start: 0 },
});
}
chunks
}
fn renderable_adornment_anchor(adornment: &InlineAdornment, text_len: u64) -> Option<u64> {
match (&adornment.placement, &adornment.content) {
(AdornmentPlacement::AtOffset, AdornmentContent::Text { .. }) => {
Some(adornment.at.min(text_len))
}
// Session 6 consumes the inlay-hint producer surface only.
// Other placements and resource handles need layout/resource
// policy, so silently ignore them until their sessions land.
_ => None,
}
}
fn push_adornments_at(
chunks: &mut Vec<RichChunk>,
adornments: &[(usize, u64, &InlineAdornment)],
next: &mut usize,
at: u64,
) {
while let Some((_, anchor, adornment)) = adornments.get(*next).copied() {
if anchor != at {
break;
}
if let AdornmentContent::Text { text, style } = &adornment.content {
chunks.push(RichChunk {
text: text.clone(),
color: Some(adornment_text_color(style.fg)),
source: ChunkSource::Adornment { anchor },
});
}
*next += 1;
}
}
fn adornment_text_color(fg: CellColor) -> glyphon::Color {
cell_color_to_glyphon(fg).unwrap_or_else(|| glyphon::Color::rgb(130, 130, 140))
}
fn source_color_at(byte: u64, spans: &[StyleSpan]) -> Option<glyphon::Color> {
for sp in spans {
if sp.range.start <= byte && byte < sp.range.end {
return cell_color_to_glyphon(sp.style.fg);
}
}
None
}
/// Convert a `pmacs-protocol::cell::Color` to a `glyphon::Color`.
/// Returns `None` for `Default` so the renderer falls back to the
/// `Attrs` default color (white-ish in our render) rather than
/// stomping with an arbitrary RGB.
///
/// `Indexed` uses the standard ANSI 16-color + 256-color cube
/// palette. The TUI interprets these via terminal-level color codes;
/// the GPU has no equivalent layer, so the palette mapping lives
/// here. Picked to roughly match `xterm-256color` defaults so
/// existing pmacs themes look consistent across both frontends.
fn cell_color_to_glyphon(c: CellColor) -> Option<glyphon::Color> {
match c {
CellColor::Default => None,
CellColor::Rgb(r, g, b) => Some(glyphon::Color::rgb(r, g, b)),
CellColor::Indexed(idx) => Some(indexed_to_glyphon(idx)),
}
}
/// The GPU's "plain" text color — the buffer-text default at
/// `main.rs`'s primary `TextArea`. The Q#TH5 mapping target for a
/// set face's `Default` fg.
fn plain_text_color() -> glyphon::Color {
glyphon::Color::rgb(230, 230, 235)
}
/// The window clear color as a quad rgba — the Q#TH5 mapping target
/// for a set face's `Default` bg (an untinted surface). Must equal
/// [`BG`].
const WINDOW_BG_RGBA: [f32; 4] = [0.05, 0.05, 0.07, 1.0];
/// glyphon (u8) → quad (f32) color, carrying `alpha`. Divides by 255
/// — the same space every existing float constant uses (e.g. the
/// error squiggle `[0.945, …]` is exactly `rgb(241, 76, 76) / 255`).
fn glyphon_to_rgba(c: glyphon::Color, alpha: f32) -> [f32; 4] {
[
f32::from(c.r()) / 255.0,
f32::from(c.g()) / 255.0,
f32::from(c.b()) / 255.0,
alpha,
]
}
/// quad (f32) → glyphon (u8) color (alpha dropped) — the `reverse`
/// swap's other direction.
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "inputs are 0.0..=1.0 quad colors by construction"
)]
fn rgba_to_glyphon(c: [f32; 4]) -> glyphon::Color {
glyphon::Color::rgb(
(c[0] * 255.0) as u8,
(c[1] * 255.0) as u8,
(c[2] * 255.0) as u8,
)
}
/// Standard xterm-style 256-color palette: 16 base colors + 6×6×6
/// RGB cube (16..=231) + 24-step grayscale (232..=255). Values
/// pulled from the conventional xterm defaults; the 6×6×6 cube uses
/// the standard step values {0, 95, 135, 175, 215, 255}.
fn indexed_to_glyphon(idx: u8) -> glyphon::Color {
const ANSI16: [(u8, u8, u8); 16] = [
(0, 0, 0), // 0 black
(205, 49, 49), // 1 red
(13, 188, 121), // 2 green
(229, 229, 16), // 3 yellow
(36, 114, 200), // 4 blue
(188, 63, 188), // 5 magenta
(17, 168, 205), // 6 cyan
(229, 229, 229), // 7 white
(102, 102, 102), // 8 bright black
(241, 76, 76), // 9 bright red
(35, 209, 139), // 10 bright green
(245, 245, 67), // 11 bright yellow
(59, 142, 234), // 12 bright blue
(214, 112, 214), // 13 bright magenta
(41, 184, 219), // 14 bright cyan
(255, 255, 255), // 15 bright white
];
if idx < 16 {
let (r, g, b) = ANSI16[idx as usize];
return glyphon::Color::rgb(r, g, b);
}
if (16..=231).contains(&idx) {
// 6×6×6 cube.
const STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
let i = idx - 16;
let r = STEPS[(i / 36) as usize];
let g = STEPS[((i / 6) % 6) as usize];
let b = STEPS[(i % 6) as usize];
return glyphon::Color::rgb(r, g, b);
}
// 232..=255: 24-step grayscale, evenly spaced 8..=238.
let level = 8 + 10 * (idx - 232);
glyphon::Color::rgb(level, level, level)
}
/// Height of the diagnostic squiggle band, in pixels (Q#W1). Taller
/// than the original straight bar (2px) so the sine wave fits: the
/// fragment shader's `amplitude + thickness` (~2.4px each side) must
/// sit inside half the band.
const DIAG_SQUIGGLE_PX: f32 = 6.0;
/// Map a [`DecorationKind`] to an underline color, or `None` for
/// kinds that don't underline.
///
/// Session 5 originally rendered diagnostics by *recoloring the text
/// foreground*, which clobbered the syntax color of the very token
/// the diagnostic points at — the same flaw the TUI fixed with
/// protocol v6's `underline_color` (T M4.6). The GPU's equivalent is
/// a wavy squiggle hugging the bottom of the glyph extent (Q#W1, was
/// a straight bar through PR #65); the text keeps its syntax color.
/// Same RGB palette the fg path used (red / yellow / light blue /
/// dim gray), so the window's severity language is unchanged.
fn decoration_kind_to_underline_color(kind: DecorationKind) -> Option<[f32; 4]> {
match kind {
// ANSI bright red — matches TUI diagnostic-error palette.
DecorationKind::DiagnosticError => Some([0.945, 0.298, 0.298, 1.0]),
// ANSI bright yellow.
DecorationKind::DiagnosticWarning => Some([0.961, 0.961, 0.263, 1.0]),
// ANSI bright blue.
DecorationKind::DiagnosticInfo => Some([0.231, 0.557, 0.918, 1.0]),
// ANSI bright black (dim gray — hints should be visible but
// visually quietest of the diagnostic four).
DecorationKind::DiagnosticHint => Some([0.4, 0.4, 0.4, 1.0]),
// Background kinds wash the full line box instead.
DecorationKind::Selection
| DecorationKind::SearchMatch
| DecorationKind::SearchMatchActive
| DecorationKind::CurrentLine => None,
}
}
/// Severity rank of a diagnostic decoration kind (UX gutter sub-arc 2):
/// `0` = most severe (`Error`) … `3` = least (`Hint`); `None` for
/// non-diagnostic kinds. Lets the gutter sign pick the most-severe
/// diagnostic touching a line (min rank wins), mirroring the TUI.
fn diagnostic_severity_rank(kind: DecorationKind) -> Option<u8> {
match kind {
DecorationKind::DiagnosticError => Some(0),
DecorationKind::DiagnosticWarning => Some(1),
DecorationKind::DiagnosticInfo => Some(2),
DecorationKind::DiagnosticHint => Some(3),
DecorationKind::Selection
| DecorationKind::SearchMatch
| DecorationKind::SearchMatchActive
| DecorationKind::CurrentLine => None,
}
}
/// Background-bearing companion to
/// [`decoration_kind_to_underline_color`]: maps each
/// background-needing `DecorationKind` to its quad-pipeline color as
/// an RGBA tuple in 0..=1 space. Returns `None` for underline-only
/// kinds (the four diagnostic severities) so the two helpers form a
/// total cover with no overlap.
///
/// Session 9.1 shipped `Selection`; session 9.2 adds `CurrentLine`.
/// `SearchMatch` / `SearchMatchActive` wait on a search feature in
/// pmacs core (Q#4 in `docs/pmacs-gpu-quad-backgrounds-framing.md`),
/// so they continue to return `None` here.
#[allow(clippy::match_same_arms)] // each `None` arm has a distinct rationale comment.
fn decoration_kind_to_bg_color(kind: DecorationKind) -> Option<[f32; 4]> {
match kind {
// Translucent blue, similar to the conventional editor
// selection background. The 0.30 alpha lets the underlying
// glyph color show through unmodified — text remains readable
// because the text render pass runs after this one in the same
// render pass (Q#2 stance α).
DecorationKind::Selection => Some([0.31, 0.42, 0.82, 0.30]),
// Blue-grey wash, quietest of the background kinds (it's always
// on) but still visible. The first 9.2/9.3 value (alpha 0.08)
// computed to ~10/255 above the dark clear color and was
// swamped by glyphs on a text line — invisible in practice.
// 0.22 keeps it subtle vs Selection's 0.30 while actually
// reading as a current-line band.
DecorationKind::CurrentLine => Some([0.55, 0.60, 0.75, 0.22]),
// In-buffer search (Q#SR4): a translucent yellow wash under
// every match, a stronger amber under the active one so it
// stands out as you step through. Both let the glyph color
// show through (text renders after this pass).
DecorationKind::SearchMatch => Some([0.85, 0.78, 0.20, 0.30]),
DecorationKind::SearchMatchActive => Some([0.95, 0.55, 0.12, 0.48]),
// Underline-only — handled by
// [`decoration_kind_to_underline_color`].
DecorationKind::DiagnosticError
| DecorationKind::DiagnosticWarning
| DecorationKind::DiagnosticInfo
| DecorationKind::DiagnosticHint => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pmacs_protocol::cell::Style;
fn style_with_fg(fg: CellColor) -> Style {
Style {
fg,
..Style::default()
}
}
fn color_close(a: [f32; 4], b: [f32; 4]) -> bool {
a.into_iter()
.zip(b)
.all(|(left, right)| (left - right).abs() < 0.001)
}
fn f32_at(bytes: &[u8], index: usize) -> f32 {
let start = index * std::mem::size_of::<f32>();
f32::from_ne_bytes(
bytes[start..start + std::mem::size_of::<f32>()]
.try_into()
.expect("f32 bytes"),
)
}
fn span(start: u64, end: u64, fg: CellColor) -> StyleSpan {
StyleSpan {
range: ByteRange { start, end },
style: style_with_fg(fg),
}
}
fn adornment(at: u64, placement: AdornmentPlacement, text: &str) -> InlineAdornment {
InlineAdornment {
at,
placement,
content: AdornmentContent::Text {
text: text.to_owned(),
style: Style::default(),
},
}
}
fn resource_adornment(at: u64, placement: AdornmentPlacement) -> InlineAdornment {
InlineAdornment {
at,
placement,
content: AdornmentContent::Resource { handle: 7 },
}
}
fn chunk_texts(chunks: &[RichChunk]) -> Vec<&str> {
chunks.iter().map(|chunk| chunk.text.as_str()).collect()
}
#[test]
fn source_line_range_locates_enclosing_line() {
// "abc\nde\nfgh": newlines at byte 3 and 6; len = 10.
let text = "abc\nde\nfgh";
// Cursor on line 0 → [0, 4) (includes the trailing \n).
assert_eq!(source_line_range(text, 0), (0, 4));
assert_eq!(source_line_range(text, 2), (0, 4));
// Start of line 1 → [4, 7).
assert_eq!(source_line_range(text, 4), (4, 7));
assert_eq!(source_line_range(text, 5), (4, 7));
// Last line has no trailing \n → [7, 10).
assert_eq!(source_line_range(text, 8), (7, 10));
// Cursor past end clamps to the last line, never indexes out.
assert_eq!(source_line_range(text, 99), (7, 10));
}
#[test]
fn translate_key_maps_motion_named_keys_and_chars() {
use winit::keyboard::{Key as WKey, ModifiersState, NamedKey, SmolStr};
let none = ModifiersState::empty();
// Motion named keys translate and are gated as motion.
for (named, expected) in [
(NamedKey::ArrowLeft, ProtocolKey::Left),
(NamedKey::ArrowRight, ProtocolKey::Right),
(NamedKey::ArrowUp, ProtocolKey::Up),
(NamedKey::ArrowDown, ProtocolKey::Down),
(NamedKey::Home, ProtocolKey::Home),
(NamedKey::End, ProtocolKey::End),
(NamedKey::PageUp, ProtocolKey::PageUp),
(NamedKey::PageDown, ProtocolKey::PageDown),
] {
let (k, m) = translate_key(&WKey::Named(named), none).expect("named maps");
assert_eq!(k, expected);
assert!(m.is_empty());
assert!(is_motion_key(k), "{expected:?} should gate as motion");
}
// A character key maps to Char but is NOT a motion key (B1
// gates it out; B2 opens it).
let (k, _) = translate_key(&WKey::Character(SmolStr::new("a")), none).expect("char maps");
assert_eq!(k, ProtocolKey::Char('a'));
assert!(!is_motion_key(k));
// Editing named keys translate (for B2) but don't gate as motion.
let (bk, _) = translate_key(&WKey::Named(NamedKey::Backspace), none).expect("bksp maps");
assert_eq!(bk, ProtocolKey::Backspace);
assert!(!is_motion_key(bk));
let (space, _) = translate_key(&WKey::Named(NamedKey::Space), none).expect("space maps");
assert_eq!(space, ProtocolKey::Char(' '));
assert!(!is_motion_key(space));
}
#[test]
fn should_forward_key_gates_editing_keys_and_excludes_chords() {
let none = Modifiers::NONE;
let ctrl = Modifiers::CTRL;
let shift = Modifiers::SHIFT;
// Plain text-editing keys forward.
for key in [
ProtocolKey::Char('a'),
ProtocolKey::Char('A'),
ProtocolKey::Backspace,
ProtocolKey::Enter,
ProtocolKey::Delete,
ProtocolKey::Tab,
] {
assert!(should_forward_key(key, none), "{key:?} should forward");
}
// Shift is not a chord modifier (Shift+a already arrives as 'A').
assert!(should_forward_key(ProtocolKey::Char('A'), shift));
// Ctrl/Alt/Meta + a non-motion key is a chord — withheld in B2.
assert!(!should_forward_key(ProtocolKey::Char('x'), ctrl));
assert!(!should_forward_key(ProtocolKey::Char('f'), Modifiers::ALT));
assert!(!should_forward_key(
ProtocolKey::Char('h'),
Modifiers::HYPER
));
// Motion keys forward regardless of modifiers (C-Left = word-left).
assert!(should_forward_key(ProtocolKey::Left, ctrl));
assert!(should_forward_key(ProtocolKey::Down, shift));
assert!(should_forward_key(ProtocolKey::PageUp, none));
// Deletion keys forward regardless of modifiers too — C-BS /
// C-DEL / M-BS are word-level deletes in the default keymap,
// the same editing-command family as chorded motion.
assert!(should_forward_key(ProtocolKey::Backspace, ctrl));
assert!(should_forward_key(ProtocolKey::Delete, ctrl));
assert!(should_forward_key(ProtocolKey::Backspace, Modifiers::ALT));
}
#[test]
fn command_chord_forwards_char_chords_with_ctrl_or_alt() {
// Q#GC1 — any Char/Enter/Tab with Ctrl or Alt is a command chord,
// forwarded to the daemon keymap. This subsumes the old allowlists
// (isearch C-s/C-r/C-M-s, clipboard M-w/C-w/C-y, M-x) plus the
// rest of the keymap (C-a, C-e, M-f, C-x, …).
let ctrl_alt = Modifiers::CTRL | Modifiers::ALT;
for (key, mods) in [
(ProtocolKey::Char('s'), Modifiers::CTRL), // isearch
(ProtocolKey::Char('s'), ctrl_alt), // regex isearch
(ProtocolKey::Char('w'), Modifiers::ALT), // copy
(ProtocolKey::Char('y'), Modifiers::CTRL), // yank
(ProtocolKey::Char('x'), Modifiers::ALT), // M-x
(ProtocolKey::Char('x'), Modifiers::CTRL), // C-x prefix
(ProtocolKey::Char('a'), Modifiers::CTRL), // line-start (was withheld)
(ProtocolKey::Char('f'), Modifiers::ALT), // forward-word
(ProtocolKey::Enter, Modifiers::CTRL),
] {
assert!(
is_command_chord(key, mods),
"{key:?}+{mods:?} is a command chord"
);
// The general forwarding is *why* these move; `should_forward_key`
// still withholds them (they're caught before it).
assert!(!should_forward_key(key, mods));
}
// Plain text, and Meta/Super-only chords, are not command chords.
assert!(!is_command_chord(ProtocolKey::Char('a'), Modifiers::NONE));
assert!(!is_command_chord(ProtocolKey::Char('c'), Modifiers::META));
// Motion isn't routed here (it keeps its own defer-aware path).
assert!(!is_command_chord(ProtocolKey::Left, Modifiers::CTRL));
}
#[test]
fn is_layout_text_distinguishes_altgr_from_command_chords() {
// Audit F-004 — AltGr (Ctrl+Alt on Windows) produces printable
// text; that's text input, so the caller strips the modifiers.
let ctrl_alt = Modifiers::CTRL | Modifiers::ALT;
assert!(is_layout_text(Some("@"), ctrl_alt));
assert!(is_layout_text(Some("{"), ctrl_alt));
assert!(is_layout_text(Some(""), ctrl_alt)); // AltGr+e on many layouts
// Alt ALONE is not AltGr. On macOS the Option key is Alt and emits
// printable text (Option+x → "≈"), but Option-as-Meta is how the
// GUI reaches M-x / M-f — leave it intact so it forwards as a
// command chord instead of self-inserting the symbol.
assert!(!is_layout_text(Some(""), Modifiers::ALT)); // macOS Option+x → M-x
assert!(!is_layout_text(Some(""), Modifiers::ALT));
// Genuine command chords produce no text (or a control char) —
// not layout text, so they still route to the keymap.
assert!(!is_layout_text(None, Modifiers::CTRL)); // C-a etc.
assert!(!is_layout_text(Some("\u{1}"), Modifiers::CTRL)); // Ctrl+A control char
assert!(!is_layout_text(Some(""), ctrl_alt)); // no text
// Plain text has no command modifier, so it's already handled and
// needs no stripping.
assert!(!is_layout_text(Some("a"), Modifiers::NONE));
assert!(!is_layout_text(Some("A"), Modifiers::SHIFT));
}
#[test]
fn mb_dropdown_window_clamps_and_keeps_selection_visible() {
// Row height is 20.0; a 1000px space fits any producer-capped list.
assert_eq!(mb_dropdown_window(5, 2, 1000.0), Some((0, 5)));
// The whole-fits path is identical to no clamp: (0, n).
assert_eq!(mb_dropdown_window(10, 9, 1000.0), Some((0, 10)));
// A 100px space fits 5 rows. Selection near the top anchors at 0.
assert_eq!(mb_dropdown_window(10, 0, 100.0), Some((0, 5)));
// Selection past the fold scrolls so it stays visible (bottom edge).
assert_eq!(mb_dropdown_window(10, 9, 100.0), Some((5, 5)));
let (first, count) = mb_dropdown_window(10, 7, 100.0).unwrap();
assert!(
first <= 7 && 7 < first + count,
"sel 7 in [{first},{})",
first + count
);
// Degenerate: too short for even one row ⇒ hide, never draw above 0.
assert_eq!(mb_dropdown_window(10, 0, 10.0), None);
// No candidates ⇒ nothing.
assert_eq!(mb_dropdown_window(0, 0, 500.0), None);
// An out-of-range selection is clamped, not panicked.
assert_eq!(mb_dropdown_window(3, 99, 1000.0), Some((0, 3)));
}
#[test]
fn translate_key_carries_modifiers() {
use winit::keyboard::{Key as WKey, ModifiersState, NamedKey};
let ctrl = ModifiersState::CONTROL;
let (k, m) = translate_key(&WKey::Named(NamedKey::ArrowLeft), ctrl).expect("maps");
assert_eq!(k, ProtocolKey::Left);
assert!(m.contains(Modifiers::CTRL));
assert!(!m.contains(Modifiers::SHIFT));
}
#[test]
fn line_byte_offsets_indexes_each_logical_line() {
// "abc\nde\nfgh": lines start at bytes 0, 4, 7. Indexed by
// LayoutRun::line_i to rebase line-relative glyph offsets.
assert_eq!(line_byte_offsets("abc\nde\nfgh"), vec![0, 4, 7]);
// Trailing newline yields a final empty line at byte len.
assert_eq!(line_byte_offsets("a\nb\n"), vec![0, 2, 4]);
// No newline: one line at 0.
assert_eq!(line_byte_offsets("abc"), vec![0]);
assert_eq!(line_byte_offsets(""), vec![0]);
}
#[test]
fn line_char_offsets_track_unicode_line_starts() {
let text = "\n😀b\n";
let (line_starts, line_char_starts) = line_offset_tables(text);
assert_eq!(line_starts, vec![0, 4, 10]);
assert_eq!(line_char_starts, vec![0, 3, 6]);
}
#[test]
fn byte_offset_for_char_offset_scans_only_within_line() {
let text = "\n😀b";
let (line_starts, line_char_starts) = line_offset_tables(text);
assert_eq!(
byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 0),
Some(0)
);
assert_eq!(
byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 2),
Some(3)
);
assert_eq!(
byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 3),
Some(4)
);
assert_eq!(
byte_offset_for_char_offset(text, &line_starts, &line_char_starts, 4),
Some(8)
);
}
#[test]
fn loro_text_delta_batch_inserts_multibyte_text_and_updates_lines() {
let mut text = "\nb".to_owned();
let (mut line_starts, mut line_char_starts) = line_offset_tables(&text);
let delta = vec![
loro::TextDelta::Retain {
retain: 3,
attributes: None,
},
loro::TextDelta::Insert {
insert: "😀\n".to_owned(),
attributes: None,
},
];
let mut edits = Vec::new();
apply_loro_text_delta_batch(
&mut text,
&mut line_starts,
&mut line_char_starts,
&delta,
&mut edits,
)
.expect("delta applies");
assert_eq!(text, "\n😀\nb");
assert_eq!((line_starts, line_char_starts), line_offset_tables(&text));
assert_eq!(
edits,
vec![TextProjectionEdit {
start: 4,
old_end: 4,
inserted_len: "😀\n".len() as u64,
}]
);
}
#[test]
fn loro_text_delta_batch_deletes_across_unicode_lines() {
let mut text = "\n😀\nb".to_owned();
let (mut line_starts, mut line_char_starts) = line_offset_tables(&text);
let delta = vec![
loro::TextDelta::Retain {
retain: 1,
attributes: None,
},
loro::TextDelta::Delete { delete: 3 },
];
let mut edits = Vec::new();
apply_loro_text_delta_batch(
&mut text,
&mut line_starts,
&mut line_char_starts,
&delta,
&mut edits,
)
.expect("delta applies");
assert_eq!(text, "a\nb");
assert_eq!((line_starts, line_char_starts), line_offset_tables(&text));
assert_eq!(
edits,
vec![TextProjectionEdit {
start: 1,
old_end: 8,
inserted_len: 0,
}]
);
}
#[test]
fn cached_style_ranges_translate_through_insertions() {
let edit = TextProjectionEdit {
start: 5,
old_end: 5,
inserted_len: 3,
};
assert_eq!(
translate_byte_range(ByteRange { start: 10, end: 14 }, edit),
Some(ByteRange { start: 13, end: 17 }),
"ranges after the insert shift right"
);
assert_eq!(
translate_byte_range(ByteRange { start: 2, end: 10 }, edit),
Some(ByteRange { start: 2, end: 13 }),
"ranges containing the insert expand"
);
assert_eq!(
translate_byte_range(ByteRange { start: 2, end: 5 }, edit),
Some(ByteRange { start: 2, end: 8 }),
"ranges ending exactly at the insert boundary extend over the typed \
text — typed chars inherit the preceding token's color until the \
next authoritative frame"
);
}
#[test]
fn optimistic_insert_text_covers_plain_chars_and_tab_but_not_enter() {
let mut buf = [0u8; 4];
let none = Modifiers::NONE;
let shift = Modifiers::SHIFT;
let ctrl = Modifiers::CTRL;
assert_eq!(
optimistic_insert_text(ProtocolKey::Char('a'), none, &mut buf),
Some("a")
);
assert_eq!(
optimistic_insert_text(ProtocolKey::Char('É'), shift, &mut buf),
Some("É"),
"shifted printable chars stay optimistic (shift is how uppercase arrives)"
);
assert_eq!(
optimistic_insert_text(ProtocolKey::Enter, none, &mut buf),
None,
"RET binds edit.newline-and-indent (Q#AI1): the inserted text depends \
on the current line, so plain Enter must round-trip — this is also \
what makes RET rebindings reachable from the GPU frontend"
);
assert_eq!(
optimistic_insert_text(ProtocolKey::Tab, none, &mut buf),
Some("\t"),
"TAB is bound to buffer.tab = insert_char(9): identical to a self-insert"
);
// Modified Enter/Tab and chords round-trip — a keymap may bind
// S-RET / C-TAB to anything.
assert_eq!(
optimistic_insert_text(ProtocolKey::Enter, shift, &mut buf),
None
);
assert_eq!(
optimistic_insert_text(ProtocolKey::Tab, ctrl, &mut buf),
None
);
assert_eq!(
optimistic_insert_text(ProtocolKey::Char('x'), ctrl, &mut buf),
None
);
// Deletions and motion still round-trip.
assert_eq!(
optimistic_insert_text(ProtocolKey::Backspace, none, &mut buf),
None
);
assert_eq!(
optimistic_insert_text(ProtocolKey::Left, none, &mut buf),
None
);
}
#[test]
fn optimistic_insert_text_round_trips_builtin_pair_chars() {
// Auto-pairing Q#AP1: the nine built-in pair chars must reach
// daemon dispatch so the opener and the pairing hook's closer
// are adjacent daemon-peer undo units. Both modifier shapes
// real keyboards produce are pinned: `[`/`]`/`'`/`` ` ``
// arrive unshifted, `(`/`)`/`{`/`}`/`"` arrive with SHIFT — a
// gate that only caught `Modifiers::NONE` would leak every
// shifted pair char back onto the optimistic path.
let mut buf = [0u8; 4];
for c in pmacs_protocol::BUILTIN_PAIR_CHARS {
assert_eq!(
optimistic_insert_text(ProtocolKey::Char(c), Modifiers::NONE, &mut buf),
None,
"unshifted {c:?} must round-trip"
);
assert_eq!(
optimistic_insert_text(ProtocolKey::Char(c), Modifiers::SHIFT, &mut buf),
None,
"shifted {c:?} must round-trip"
);
}
}
/// Q#R1 parity invariant: the per-line surgery's chunk source
/// (`clipped_chunks_for_range` over one line's content range)
/// must agree byte-for-byte — text AND color — with the full
/// slice walk split at line boundaries. Pinned here so the
/// surgically rebuilt `BufferLine` can't drift from what a full
/// `set_rich_text` would have produced.
#[test]
fn per_line_chunks_match_the_full_walk() {
// (byte, color) stream, with `\n` bytes dropped — the full
// walk keeps them inside source chunks; per-line walks
// exclude them (cosmic strips the separator per line).
fn flat(chunks: &[RichChunk]) -> Vec<(u8, Option<u32>)> {
chunks
.iter()
.flat_map(|c| {
let color = c.color.map(|col| col.0);
c.text
.bytes()
.filter(|&b| b != b'\n')
.map(move |b| (b, color))
.collect::<Vec<_>>()
})
.collect()
}
// Two content lines + trailing newline. A span crossing the
// line break, plus two inlay hints: one mid-line-1, one
// anchored EXACTLY at line 0's newline (the predicted-finding
// #1 boundary case — it must belong to line 0, before the \n).
let text = "alpha BETA\ngamma delta\n";
let spans = vec![StyleSpan {
range: ByteRange { start: 6, end: 16 },
style: CellStyle {
fg: CellColor::Indexed(2),
..CellStyle::default()
},
}];
let hint = |at: u64, label: &str| InlineAdornment {
at,
placement: AdornmentPlacement::AtOffset,
content: AdornmentContent::Text {
text: label.to_owned(),
style: CellStyle::default(),
},
};
let adornments = vec![hint(10, "<eol>"), hint(17, ": T ")];
let full = flat(&clipped_chunks_for_range(
text,
&spans,
&adornments,
0,
text.len() as u64,
));
// Line ranges as the surgery computes them: content excludes
// the newline; the phantom line after the trailing `\n` is
// empty.
let mut per_line = Vec::new();
for (start, content_end) in [(0u64, 10u64), (11, 22), (23, 23)] {
per_line.extend(flat(&clipped_chunks_for_range(
text,
&spans,
&adornments,
start,
content_end,
)));
}
assert_eq!(
per_line, full,
"per-line chunk walks must reproduce the full walk exactly \
(text and colors, newlines excluded)"
);
// The boundary hint landed on line 0 (before its newline), not
// line 1.
let line0 = clipped_chunks_for_range(text, &spans, &adornments, 0, 10);
assert!(
line0.iter().any(|c| c.text == "<eol>"),
"newline-anchored hint belongs to the line it terminates"
);
let line1 = clipped_chunks_for_range(text, &spans, &adornments, 11, 22);
assert!(
line1.iter().all(|c| c.text != "<eol>"),
"newline-anchored hint must not duplicate onto the next line"
);
assert!(
line1.iter().any(|c| c.text == ": T "),
"mid-line hint renders on its own line"
);
}
#[test]
fn hit_runs_map_projected_bytes_back_to_source() {
// Source slice "ab\ncd" with an inlay hint ": i32 " anchored
// at byte 2 (end of "ab"): projected text = "ab: i32 \ncd".
let chunks = vec![
RichChunk {
text: "ab".into(),
color: None,
source: ChunkSource::Source { start: 0 },
},
RichChunk {
text: ": i32 ".into(),
color: None,
source: ChunkSource::Adornment { anchor: 2 },
},
RichChunk {
text: "\ncd".into(),
color: None,
source: ChunkSource::Source { start: 2 },
},
];
let (runs, line_starts) = build_hit_runs(&chunks);
assert_eq!(
line_starts,
vec![0, 9],
"projected line table counts the newline at projected byte 8"
);
// Hits inside source runs map linearly.
assert_eq!(projected_to_source(&runs, 0), Some(0));
assert_eq!(projected_to_source(&runs, 1), Some(1));
assert_eq!(
projected_to_source(&runs, 9),
Some(3),
"projected 'c' (byte 9) maps to source byte 3"
);
// Hits inside the adornment snap to its anchor.
for projected in 2..8 {
assert_eq!(
projected_to_source(&runs, projected),
Some(2),
"adornment hit at projected {projected} snaps to the anchor"
);
}
// Past-the-end hits clamp into the last run.
assert_eq!(projected_to_source(&runs, 999), Some(5));
// Empty map: nothing to hit.
assert_eq!(projected_to_source(&[], 0), None);
}
#[test]
fn optimistic_delete_range_covers_single_codepoints_only() {
let none = Modifiers::NONE;
let text = "aé😀b";
// Backspace deletes the codepoint before the cursor, whatever
// its width: 'é' is 2 bytes, '😀' is 4.
assert_eq!(
optimistic_delete_range(text, 3, ProtocolKey::Backspace, none),
Some((1, 3)),
"backspace before the cursor crosses the full 'é'"
);
assert_eq!(
optimistic_delete_range(text, 7, ProtocolKey::Backspace, none),
Some((3, 7)),
"backspace crosses the full '😀'"
);
// Delete removes the codepoint at the cursor.
assert_eq!(
optimistic_delete_range(text, 1, ProtocolKey::Delete, none),
Some((1, 3))
);
assert_eq!(
optimistic_delete_range(text, 7, ProtocolKey::Delete, none),
Some((7, 8))
);
// Buffer edges: nothing to delete ⇒ round-trip (daemon no-op).
assert_eq!(
optimistic_delete_range(text, 0, ProtocolKey::Backspace, none),
None
);
assert_eq!(
optimistic_delete_range(text, text.len(), ProtocolKey::Delete, none),
None
);
// Mid-codepoint (stale) cursor ⇒ round-trip, never a panic.
assert_eq!(
optimistic_delete_range(text, 2, ProtocolKey::Backspace, none),
None
);
// Modified variants are separate bindings (C-BS word delete).
assert_eq!(
optimistic_delete_range(text, 3, ProtocolKey::Backspace, Modifiers::CTRL),
None
);
// Non-delete keys are not this helper's business.
assert_eq!(
optimistic_delete_range(text, 3, ProtocolKey::Char('x'), none),
None
);
}
#[test]
fn incoming_frames_translate_through_unconfirmed_edits() {
// A frame computed at daemon generation G arrives while one
// local optimistic insert (scalar G+1: 3 bytes at byte 5) is
// still unconfirmed: the frame's ranges must shift through it.
let unconfirmed = vec![(
11u64,
TextProjectionEdit {
start: 5,
old_end: 5,
inserted_len: 3,
},
)];
let segments = vec![StyleSegment {
range: ByteRange { start: 0, end: 20 },
spans: vec![
StyleSpan {
range: ByteRange { start: 2, end: 4 },
style: CellStyle::default(),
},
StyleSpan {
range: ByteRange { start: 10, end: 14 },
style: CellStyle::default(),
},
],
}];
let translated = translate_style_segments(segments, &unconfirmed);
assert_eq!(translated.len(), 1);
assert_eq!(
translated[0].range,
ByteRange { start: 0, end: 23 },
"segment range expands over the unconfirmed insert"
);
assert_eq!(
translated[0].spans[0].range,
ByteRange { start: 2, end: 4 },
"spans before the insert are untouched"
);
assert_eq!(
translated[0].spans[1].range,
ByteRange { start: 13, end: 17 },
"spans after the insert shift right by its length"
);
// With no unconfirmed edits the frame passes through as-is.
let untouched = translate_style_segments(
vec![StyleSegment {
range: ByteRange { start: 0, end: 20 },
spans: Vec::new(),
}],
&[],
);
assert_eq!(untouched[0].range, ByteRange { start: 0, end: 20 });
}
#[test]
fn cached_style_ranges_translate_through_deletions() {
let edit = TextProjectionEdit {
start: 5,
old_end: 9,
inserted_len: 0,
};
assert_eq!(
translate_byte_range(ByteRange { start: 12, end: 16 }, edit),
Some(ByteRange { start: 8, end: 12 }),
"ranges after the deletion shift left"
);
assert_eq!(
translate_byte_range(ByteRange { start: 3, end: 12 }, edit),
Some(ByteRange { start: 3, end: 8 }),
"ranges spanning the deletion shrink"
);
assert_eq!(
translate_byte_range(ByteRange { start: 6, end: 8 }, edit),
None,
"ranges fully removed by the deletion drop"
);
}
#[test]
fn editing_a_diagnostic_clears_it_optimistically_but_holds_untouched_ones() {
let diag = |start, end| Decoration {
range: ByteRange { start, end },
kind: DecorationKind::DiagnosticError,
};
// Insert the missing char right at the 1-byte widened anchor of
// an end-of-line "expected COMMA": the squiggle clears now.
let mut decos = vec![diag(11, 12), diag(40, 50)];
translate_decorations(
&mut decos,
TextProjectionEdit {
start: 11,
old_end: 11,
inserted_len: 1,
},
);
// The touched diagnostic is gone; the far one is kept (shifted
// right by the insert) — hold-while-stale still applies to it.
assert_eq!(decos.len(), 1, "only the touched diagnostic clears");
assert_eq!(decos[0].range, ByteRange { start: 41, end: 51 });
// A non-diagnostic decoration over the edited region is never
// dropped — selection / current-line translate as before.
let mut sel = vec![Decoration {
range: ByteRange { start: 8, end: 14 },
kind: DecorationKind::Selection,
}];
translate_decorations(
&mut sel,
TextProjectionEdit {
start: 10,
old_end: 10,
inserted_len: 2,
},
);
assert_eq!(sel.len(), 1, "selection survives an edit in its range");
}
#[test]
fn source_line_range_handles_empty_and_leading_newline() {
assert_eq!(source_line_range("", 0), (0, 0));
// "\nx": cursor 0 is on the empty first line [0, 1).
assert_eq!(source_line_range("\nx", 0), (0, 1));
// cursor 1 is on line 1 → [1, 2).
assert_eq!(source_line_range("\nx", 1), (1, 2));
}
#[test]
fn bg_color_helper_covers_selection_current_line_and_search() {
// Sessions 9.1 + 9.2: Selection and CurrentLine paint.
assert!(decoration_kind_to_bg_color(DecorationKind::Selection).is_some());
assert!(decoration_kind_to_bg_color(DecorationKind::CurrentLine).is_some());
// In-buffer search (Q#SR4): both match kinds wash a bg.
assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatch).is_some());
assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatchActive).is_some());
// Underline-only kinds belong to the underline helper (T M4.6
// parity: squiggle bars, not text recoloring).
for kind in [
DecorationKind::DiagnosticError,
DecorationKind::DiagnosticWarning,
DecorationKind::DiagnosticInfo,
DecorationKind::DiagnosticHint,
] {
assert!(decoration_kind_to_bg_color(kind).is_none());
assert!(decoration_kind_to_underline_color(kind).is_some());
}
}
#[test]
fn underline_and_bg_helpers_are_disjoint_total_cover() {
// Every DecorationKind is renderable by exactly one helper.
// Adding a new kind without updating one of the helpers should
// fail this assertion.
for kind in [
DecorationKind::Selection,
DecorationKind::SearchMatch,
DecorationKind::SearchMatchActive,
DecorationKind::CurrentLine,
DecorationKind::DiagnosticError,
DecorationKind::DiagnosticWarning,
DecorationKind::DiagnosticInfo,
DecorationKind::DiagnosticHint,
] {
let ul = decoration_kind_to_underline_color(kind).is_some();
let bg = decoration_kind_to_bg_color(kind).is_some();
assert!(
ul ^ bg,
"{kind:?}: underline={ul} bg={bg} — should be exactly one"
);
}
}
#[test]
fn projected_rich_chunks_tolerates_mid_codepoint_boundaries() {
// Stale span offsets (from a prior generation) can land inside a
// multi-byte char after an edit. "ab→cd": '→' is the 3 bytes
// [2,5); a span ending at byte 3 is mid-codepoint and must not
// panic the slice — it floors to the char start.
let text = "ab→cd";
let chunks = projected_rich_chunks(
text,
&[
span(0, 3, CellColor::Indexed(1)),
span(4, 9, CellColor::Indexed(2)),
],
&[],
);
let rendered: String = chunks.iter().map(|chunk| chunk.text.as_str()).collect();
assert_eq!(rendered, text, "chunks must reassemble the original text");
}
#[test]
fn clip_rebase_range_clips_to_slice_and_subtracts_vstart() {
// Visible slice is whole-file bytes [10, 20).
assert_eq!(clip_rebase_range(12, 18, 10, 20), Some((2, 8))); // inside
assert_eq!(clip_rebase_range(5, 15, 10, 20), Some((0, 5))); // clipped left
assert_eq!(clip_rebase_range(15, 25, 10, 20), Some((5, 10))); // clipped right
assert_eq!(clip_rebase_range(10, 20, 10, 20), Some((0, 10))); // exact
assert_eq!(clip_rebase_range(0, 8, 10, 20), None); // entirely before
assert_eq!(clip_rebase_range(20, 30, 10, 20), None); // entirely after
assert_eq!(clip_rebase_range(14, 14, 10, 20), None); // empty range
// vstart 0 is the unscrolled identity case.
assert_eq!(clip_rebase_range(3, 7, 0, 100), Some((3, 7)));
}
#[test]
fn floor_char_boundary_snaps_into_multibyte_char() {
let text = "ab→cd"; // '→' = bytes [2,5)
assert_eq!(floor_char_boundary(text, 0), 0);
assert_eq!(floor_char_boundary(text, 2), 2);
assert_eq!(floor_char_boundary(text, 3), 2); // inside '→' → floor to 2
assert_eq!(floor_char_boundary(text, 4), 2);
assert_eq!(floor_char_boundary(text, 5), 5);
assert_eq!(floor_char_boundary(text, 99), text.len());
}
#[test]
fn projected_rich_chunks_inserts_at_offset_without_source_bytes() {
let chunks = projected_rich_chunks(
"abcd",
&[],
&[adornment(2, AdornmentPlacement::AtOffset, "X")],
);
assert_eq!(chunk_texts(&chunks), vec!["ab", "X", "cd"]);
let rendered: String = chunks.iter().map(|chunk| chunk.text.as_str()).collect();
assert_eq!(rendered, "abXcd");
}
#[test]
fn inline_adornment_does_not_shift_source_style_ranges() {
let chunks = projected_rich_chunks(
"abcd",
&[span(2, 4, CellColor::Indexed(1))],
&[adornment(2, AdornmentPlacement::AtOffset, "X")],
);
assert_eq!(chunk_texts(&chunks), vec!["ab", "X", "cd"]);
assert!(chunks[0].color.is_none());
assert!(
chunks[1].color.is_some(),
"default-styled virtual text should render as muted adornment text"
);
assert!(
chunks[2].color.is_some(),
"source styling must still begin at source byte 2"
);
}
#[test]
fn unsupported_adornment_placements_are_ignored_for_session_6() {
let chunks = projected_rich_chunks(
"abcd",
&[],
&[
adornment(0, AdornmentPlacement::BeforeLine, "before"),
adornment(4, AdornmentPlacement::EndOfLine, "end"),
resource_adornment(2, AdornmentPlacement::AtOffset),
],
);
assert_eq!(chunk_texts(&chunks), vec!["abcd"]);
}
#[test]
fn adornment_anchor_past_end_clamps_to_end() {
let chunks = projected_rich_chunks(
"abcd",
&[],
&[adornment(99, AdornmentPlacement::AtOffset, "X")],
);
assert_eq!(chunk_texts(&chunks), vec!["abcd", "X"]);
}
#[test]
fn minimap_band_and_inverse_line_mapping() {
// 800×600 surface: band x = [800-12-48, 800-12) = [740, 788).
// The status band reserves 26px (Q#S3), so the text area
// ends at 574 and the minimap column is y = [12, 562)
// (height 550).
assert!(minimap_band_contains(750.0, 100.0, 800, 600));
assert!(
!minimap_band_contains(739.0, 100.0, 800, 600),
"left of band"
);
assert!(
!minimap_band_contains(788.0, 100.0, 800, 600),
"right of band"
);
assert!(!minimap_band_contains(750.0, 5.0, 800, 600), "above band");
assert!(
!minimap_band_contains(750.0, 563.0, 800, 600),
"below band (status strip)"
);
// Too-narrow surfaces have no minimap at all.
assert!(!minimap_band_contains(100.0, 100.0, 150, 600));
// Inverse mapping: height = 550; 100 lines. Top → line 0,
// bottom → last line, midpoint → ~half.
assert_eq!(minimap_y_to_line(12.0, 600, 100), Some(0));
assert_eq!(minimap_y_to_line(561.9, 600, 100), Some(99));
assert_eq!(minimap_y_to_line(12.0 + 275.0, 600, 100), Some(50));
// Out-of-band y clamps rather than panics (scrubbing wanders).
assert_eq!(minimap_y_to_line(0.0, 600, 100), Some(0));
assert_eq!(minimap_y_to_line(9999.0, 600, 100), Some(99));
assert_eq!(minimap_y_to_line(100.0, 600, 0), None, "empty file");
}
#[test]
fn edge_scroll_direction_bands() {
// 600px surface: up-band y < 16 + 24 = 40; the text area
// ends at 574 (status band, Q#S3), so the down-band is
// y > 574 - 24 = 550.
assert_eq!(edge_scroll_direction(10.0, 600), Some(-1));
assert_eq!(edge_scroll_direction(39.9, 600), Some(-1));
assert_eq!(edge_scroll_direction(40.0, 600), None, "interior");
assert_eq!(edge_scroll_direction(300.0, 600), None);
assert_eq!(
edge_scroll_direction(550.0, 600),
None,
"band edge exclusive"
);
assert_eq!(edge_scroll_direction(551.0, 600), Some(1));
}
#[test]
fn scroll_indicator_matches_tui_formula() {
// Verbatim port of the TUI's format (Q#S1) — both frontends
// must read the same.
assert_eq!(format_scroll_indicator(0, 10, 1, 0), "All");
assert_eq!(format_scroll_indicator(0, 50, 30, 10), "All");
assert_eq!(format_scroll_indicator(0, 10, 100, 5), "Top");
assert_eq!(format_scroll_indicator(90, 10, 100, 95), "Bot");
assert_eq!(format_scroll_indicator(40, 10, 100, 49), "50%");
}
#[test]
fn minimap_rects_project_line_styles_as_right_side_bands() {
let red = style_with_fg(CellColor::Rgb(255, 0, 0));
let blue = style_with_fg(CellColor::Rgb(0, 0, 255));
let shapes = minimap_line_shapes("alpha\nbeta\ngamma\ndelta");
let rects = minimap_rects(&[red, red, blue, blue], &shapes, 240, 80, 0, 2);
assert!(
rects
.iter()
.any(|r| color_close(r.color, rgb_to_minimap_color(255, 0, 0))),
"red line summary band should render"
);
assert!(
rects
.iter()
.any(|r| color_close(r.color, rgb_to_minimap_color(0, 0, 255))),
"blue line summary band should render"
);
assert!(
rects
.iter()
.any(|r| color_close(r.color, MINIMAP_THUMB_FILL)),
"visible-window affordance should render"
);
}
#[test]
fn minimap_rects_bucket_large_files_to_pixel_rows() {
let red = style_with_fg(CellColor::Rgb(255, 0, 0));
let blue = style_with_fg(CellColor::Rgb(0, 0, 255));
let lines: Vec<_> = (0..10_000)
.map(|idx| if idx % 2 == 0 { red } else { blue })
.collect();
let shapes = vec![
MinimapLineShape {
indent_cols: 0,
content_cols: 40,
};
lines.len()
];
let rects = minimap_rects(&lines, &shapes, 240, 120, 0, 30);
let pixel_rows = (120.0 - MINIMAP_TOP - MINIMAP_BOTTOM).round() as usize;
assert!(
rects.len() <= pixel_rows + 4,
"minimap must bucket by visible rows, not emit per source line"
);
}
#[test]
fn minimap_hidden_when_surface_is_too_narrow() {
let lines = [style_with_fg(CellColor::Rgb(255, 0, 0))];
let shapes = [MinimapLineShape {
indent_cols: 0,
content_cols: 10,
}];
assert!(minimap_rects(&lines, &shapes, 120, 120, 0, 1).is_empty());
}
#[test]
fn minimap_rects_use_line_shape_for_indent_and_length() {
let red = style_with_fg(CellColor::Rgb(255, 0, 0));
let shapes = [
MinimapLineShape {
indent_cols: 0,
content_cols: 80,
},
MinimapLineShape {
indent_cols: 24,
content_cols: 12,
},
];
let rects = minimap_rects(&[red, red], &shapes, 240, 80, 0, 2);
let strokes: Vec<_> = rects
.iter()
.filter(|r| color_close(r.color, rgb_to_minimap_color(255, 0, 0)))
.collect();
assert_eq!(strokes.len(), 2);
assert!(
strokes[1].x > strokes[0].x,
"indented source line should shift right in the minimap"
);
assert!(
strokes[1].w < strokes[0].w,
"shorter source line should draw a shorter minimap stroke"
);
}
#[test]
fn minimap_line_shapes_preserve_trailing_empty_line() {
let shapes = minimap_line_shapes("a\n");
assert_eq!(
shapes,
vec![
MinimapLineShape {
indent_cols: 0,
content_cols: 1,
},
MinimapLineShape::default(),
]
);
}
#[test]
fn minimap_rects_encode_six_vertices_per_quad() {
let rect = MinimapRect {
x: 0.0,
y: 0.0,
w: 10.0,
h: 10.0,
color: rgb_to_minimap_color(255, 0, 0),
};
let bytes = rects_to_vertex_bytes(&[rect], 100, 100);
assert_eq!(bytes.len(), 6 * QUAD_VERTEX_STRIDE as usize);
assert!((f32_at(&bytes, 0) + 1.0).abs() < 0.001);
assert!((f32_at(&bytes, 1) - 1.0).abs() < 0.001);
assert!((f32_at(&bytes, 2) - 1.0).abs() < 0.001);
}
#[test]
fn squiggle_vertices_carry_centerline_relative_uv() {
// Q#W1: uv.x is the absolute screen pixel x (so phase is
// continuous across rects); uv.y is signed px from the band
// centerline (±h/2). Layout per vertex: [ndc_x, ndc_y, uv_x,
// uv_y, r, g, b, a] — 8 floats.
let rect = MinimapRect {
x: 10.0,
y: 20.0,
w: 30.0,
h: 6.0,
color: [0.9, 0.1, 0.2, 1.0],
};
let bytes = squiggles_to_vertex_bytes(&[rect], 100, 100);
assert_eq!(bytes.len(), 6 * SQUIGGLE_VERTEX_STRIDE as usize);
// Vertex 0 = top-left: uv = (left x, -h/2).
assert!((f32_at(&bytes, 2) - 10.0).abs() < 0.001, "uv.x left");
assert!((f32_at(&bytes, 3) + 3.0).abs() < 0.001, "uv.y top = -h/2");
// Color rides every vertex.
assert!((f32_at(&bytes, 4) - 0.9).abs() < 0.001);
assert!((f32_at(&bytes, 7) - 1.0).abs() < 0.001);
// Vertex 2 = bottom-right (8 floats in): uv = (right x, +h/2).
assert!((f32_at(&bytes, 16 + 2) - 40.0).abs() < 0.001, "uv.x right");
assert!((f32_at(&bytes, 16 + 3) - 3.0).abs() < 0.001, "uv.y bottom");
}
#[test]
fn squiggles_skip_degenerate_rects() {
let zero_w = MinimapRect {
x: 0.0,
y: 0.0,
w: 0.0,
h: 6.0,
color: [1.0, 0.0, 0.0, 1.0],
};
assert!(squiggles_to_vertex_bytes(&[zero_w], 100, 100).is_empty());
assert!(squiggles_to_vertex_bytes(&[zero_w], 0, 100).is_empty());
}
// --- Headless render harness (F-014) ---------------------------------
//
// These render a real frame through the actual `render_to_view`
// composition path to an offscreen texture and read the pixels back.
// They skip (not fail) when no wgpu adapter is available — a dev box
// without working Vulkan, or CI without lavapipe.
/// Build a headless `State`, or return `None` and log when there's no
/// adapter so the caller can skip. When `PMACS_REQUIRE_GPU` is set
/// (CI, where lavapipe is installed) a missing adapter is a hard
/// failure instead — so a broken software-rasterizer setup can't pass
/// as a silently-skipped green (F-014).
fn headless_or_skip(width: u32, height: u32, text: &str) -> Option<State> {
let state = State::new_headless(width, height, text);
if state.is_none() {
assert!(
std::env::var_os("PMACS_REQUIRE_GPU").is_none(),
"PMACS_REQUIRE_GPU is set but no wgpu adapter was available"
);
eprintln!("skipping headless render test: no wgpu adapter available");
}
state
}
#[test]
fn headless_render_produces_a_full_nonblank_frame() {
let Some(mut state) = headless_or_skip(320, 240, "fn main() {}") else {
return;
};
let px = state.render_offscreen();
assert_eq!(px.len(), 320 * 240 * 4, "packed RGBA8 of the whole frame");
// A real frame varies (text ink over the background). A single
// uniform value would mean nothing composited.
let first = px[0];
assert!(
px.iter().any(|&b| b != first),
"frame is a single uniform value — nothing appears to have rendered"
);
}
#[test]
fn completion_popup_is_scoped_to_its_buffer() {
// Buffer-switch regression (PR #93 validation finding 1): a
// retained popup mirror must be inert — no key gating, no
// anchor mapping — the moment `current_buffer_id` differs
// from the popup's buffer.
let Some(mut state) = headless_or_skip(320, 240, "hello_world he") else {
return;
};
let own = BufferId::next();
let other = BufferId::next();
state.current_buffer_id = Some(own);
// Headless states never declare a viewport; anchor mapping
// reads `view_range`, so pin it to the whole text.
state.view_range = (0, state.current_text.len() as u64);
state.completion = Some(CompletionLocal {
buffer_id: own,
anchor: 12,
prefix_len: 2,
rows: vec![CompletionPopupRow {
label: "hello_world".into(),
kind: 3,
detail: None,
}],
selected: Some(0),
total: 1,
});
assert!(state.completion_open_for_current_buffer());
assert!(
state.completion_anchor_px().is_some(),
"the popup anchors in its own buffer"
);
// The window switches buffers; the mirror is stale.
state.current_buffer_id = Some(other);
assert!(
!state.completion_open_for_current_buffer(),
"a foreign-buffer popup must not gate keys"
);
assert!(
state.completion_anchor_px().is_none(),
"a foreign-buffer popup must not paint"
);
}
#[test]
fn headless_text_changes_the_rendered_frame() {
let Some(mut empty) = headless_or_skip(320, 240, "") else {
return;
};
let empty_px = empty.render_offscreen();
let mut with_text =
State::new_headless(320, 240, "hello pmacs").expect("adapter was just available");
let text_px = with_text.render_offscreen();
assert_eq!(empty_px.len(), text_px.len());
let differing = empty_px
.iter()
.zip(&text_px)
.filter(|(a, b)| a != b)
.count();
assert!(
differing > 200,
"text should paint visible ink (only {differing} bytes differ from the empty frame)"
);
}
#[test]
fn headless_line_number_gutter_changes_the_frame() {
// UX gutter: enabling line numbers must add ink on the left and
// shift the code right — the rendered frame must differ.
let Some(mut off) = headless_or_skip(400, 300, "alpha\nbeta\ngamma\ndelta\n") else {
return;
};
let off_px = off.render_offscreen();
let mut on = State::new_headless(400, 300, "alpha\nbeta\ngamma\ndelta\n")
.expect("adapter was just available");
on.line_numbers = LineNumberMode::Absolute;
let on_px = on.render_offscreen();
assert_eq!(off_px.len(), on_px.len());
let differing = off_px.iter().zip(&on_px).filter(|(a, b)| a != b).count();
assert!(
differing > 200,
"the gutter should add ink + shift the text (only {differing} bytes differ)"
);
}
#[test]
fn headless_diagnostic_gutter_sign_changes_the_frame() {
// UX gutter sub-arc 2: with the gutter on, a diagnostic on a line
// must add a severity-colored sign bar in the gutter — the frame
// must differ from the same gutter with no diagnostics.
let text = "alpha\nbeta\ngamma\n";
let Some(mut plain) = headless_or_skip(400, 300, text) else {
return;
};
plain.line_numbers = LineNumberMode::Absolute;
plain.current_buffer_id = Some(BufferId::next());
plain.view_range = (0, text.len() as u64);
let plain_px = plain.render_offscreen();
let mut with_diag =
State::new_headless(400, 300, text).expect("adapter was just available");
with_diag.line_numbers = LineNumberMode::Absolute;
with_diag.current_buffer_id = Some(BufferId::next());
with_diag.view_range = (0, text.len() as u64);
with_diag.current_decorations.push(Decoration {
range: ByteRange { start: 0, end: 5 }, // "alpha"
kind: DecorationKind::DiagnosticError,
});
let diag_px = with_diag.render_offscreen();
assert_eq!(plain_px.len(), diag_px.len());
let differing = plain_px
.iter()
.zip(&diag_px)
.filter(|(a, b)| a != b)
.count();
assert!(
differing > 20,
"the diagnostic sign bar should add ink ({differing} bytes differ)"
);
}
#[test]
fn headless_relative_mode_renders_differently_from_absolute() {
// Sub-arc 3: with the cursor on line 2, relative numbering
// (2,1,0,1,2) must differ from absolute (1,2,3,4,5) — proving the
// GPU renders the mode against its own cursor line.
let text = "alpha\nbeta\ngamma\ndelta\nepsilon\n";
let bid = BufferId::next();
let cursor = OwnCursor {
buffer_id: bid,
byte: 13, // inside "gamma" (buffer line 2)
};
let Some(mut abs) = headless_or_skip(400, 300, text) else {
return;
};
abs.current_buffer_id = Some(bid);
abs.view_range = (0, text.len() as u64);
abs.own_cursor = Some(cursor);
abs.line_numbers = LineNumberMode::Absolute;
let abs_px = abs.render_offscreen();
let mut rel = State::new_headless(400, 300, text).expect("adapter was just available");
rel.current_buffer_id = Some(bid);
rel.view_range = (0, text.len() as u64);
rel.own_cursor = Some(cursor);
rel.line_numbers = LineNumberMode::Relative;
let rel_px = rel.render_offscreen();
assert_eq!(abs_px.len(), rel_px.len());
let differing = abs_px.iter().zip(&rel_px).filter(|(a, b)| a != b).count();
assert!(
differing > 20,
"relative numbering must differ from absolute ({differing} bytes differ)"
);
}
#[test]
fn gutter_aware_rel_x_clamps_the_gutter_band() {
// F1: a click in the gutter band (left of the text origin) clamps
// to the line start (rel_x 0), never a negative x into glyphon.
let text = "alpha\nbeta\ngamma\n";
let Some(mut s) = headless_or_skip(400, 300, text) else {
return;
};
s.line_numbers = LineNumberMode::Absolute;
let text_left = f64::from(s.text_left());
assert!(text_left > f64::from(TEXT_LEFT), "the gutter is present");
// Inside the gutter band and at the exact origin → clamped to 0.
assert!(s.gutter_aware_rel_x(text_left - 4.0).abs() < f32::EPSILON);
assert!(s.gutter_aware_rel_x(text_left).abs() < f32::EPSILON);
// Well into the text → a positive text-relative x.
assert!(s.gutter_aware_rel_x(text_left + 40.0) > 0.0);
// With the gutter off there's no band, so a left-of-origin x passes
// through negative (the pre-gutter behavior is unchanged).
s.line_numbers = LineNumberMode::Off;
assert!(s.gutter_aware_rel_x(f64::from(TEXT_LEFT) - 4.0) < 0.0);
}
#[test]
fn narrow_window_drops_the_gutter() {
// F2: a window too narrow to fit the gutter + a minimum text area
// drops the gutter for the frame (no `left >= right`), mirroring the
// TUI. A wide window keeps it.
let text = "l1\nl2\nl3\n";
let Some(mut narrow) = headless_or_skip(60, 200, text) else {
return;
};
narrow.line_numbers = LineNumberMode::Absolute;
assert!(
narrow.gutter_width_px() < f32::EPSILON,
"a 60px window can't fit gutter + min text → gutter dropped"
);
assert!(
(narrow.text_left() - TEXT_LEFT).abs() < f32::EPSILON,
"text origin unshifted when the gutter is dropped"
);
let mut wide = State::new_headless(800, 200, text).expect("adapter was just available");
wide.line_numbers = LineNumberMode::Absolute;
assert!(
wide.gutter_width_px() > 0.0,
"a wide window keeps the gutter"
);
}
// -----------------------------------------------------------------
// Themes (Q#TH5/Q#TH7/Q#TH8): GPU face application.
// -----------------------------------------------------------------
fn theme_face(name: &str, style: CellStyle) -> pmacs_protocol::ThemeFace {
pmacs_protocol::ThemeFace {
name: name.into(),
style,
}
}
fn apply_faces(state: &mut State, faces: Vec<pmacs_protocol::ThemeFace>) {
let _ = state.apply_attach_message(InstanceMessage::ThemeFacts { faces });
}
fn px_at(px: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] {
let i = ((y * width + x) * 4) as usize;
[px[i], px[i + 1], px[i + 2], px[i + 3]]
}
#[test]
fn headless_theme_facts_empty_table_renders_identically() {
// Acceptance 20: the authoritative empty table (an unthemed
// daemon's first send) must change nothing.
let Some(mut state) = headless_or_skip(320, 240, "fn main() {}") else {
return;
};
let base = state.render_offscreen();
apply_faces(&mut state, Vec::new());
let themed = state.render_offscreen();
assert_eq!(base, themed, "an empty face table must be a no-op");
}
#[test]
fn headless_modeline_face_owns_the_band_and_reverse_swaps() {
// Acceptance 20: ui.modeline bg retints the band quad, and
// reverse swaps quad/text after the Default mapping. Sampled
// at a band pixel left of the text pad — comparing two states
// whose (fg, bg, reverse) SHOULD produce the same quad avoids
// any dependence on the surface's exact color space.
let (w, h) = (400u32, 300u32);
let fg = CellColor::Rgb(200, 30, 30);
let bg = CellColor::Rgb(10, 60, 110);
let Some(mut plain) = headless_or_skip(w, h, "hello") else {
return;
};
let plain_sample = px_at(&plain.render_offscreen(), w, 2, h - 2);
apply_faces(
&mut plain,
vec![theme_face(
"ui.modeline",
CellStyle {
fg,
bg,
..CellStyle::default()
},
)],
);
let tinted_sample = px_at(&plain.render_offscreen(), w, 2, h - 2);
assert_ne!(
plain_sample, tinted_sample,
"a bg face must retint the band quad"
);
// Swapped face + reverse ⇒ the identical quad color…
apply_faces(
&mut plain,
vec![theme_face(
"ui.modeline",
CellStyle {
fg: bg,
bg: fg,
reverse: true,
..CellStyle::default()
},
)],
);
let swapped_sample = px_at(&plain.render_offscreen(), w, 2, h - 2);
assert_eq!(
tinted_sample, swapped_sample,
"reverse must swap fg/bg after mapping (same quad both ways)"
);
// …while reverse on the ORIGINAL face tints the quad with fg.
apply_faces(
&mut plain,
vec![theme_face(
"ui.modeline",
CellStyle {
fg,
bg,
reverse: true,
..CellStyle::default()
},
)],
);
let reversed_sample = px_at(&plain.render_offscreen(), w, 2, h - 2);
assert_ne!(
tinted_sample, reversed_sample,
"reverse must move the quad off the bg color"
);
}
#[test]
fn headless_gutter_face_recolors_only_the_gutter_region() {
// Acceptance 20: a ui.gutter fg change alters the gutter
// digits and nothing else — every differing pixel lies left
// of the text origin and above the band.
let (w, h) = (400u32, 300u32);
let text = "alpha\nbeta\ngamma\ndelta\n";
let Some(mut state) = headless_or_skip(w, h, text) else {
return;
};
state.line_numbers = LineNumberMode::Absolute;
let text_left = state.text_left().ceil() as u32;
let band_top = text_area_bottom(h).floor() as u32;
let base = state.render_offscreen();
apply_faces(
&mut state,
vec![theme_face(
"ui.gutter",
CellStyle {
fg: CellColor::Rgb(220, 120, 40),
..CellStyle::default()
},
)],
);
let themed = state.render_offscreen();
let mut differing = 0usize;
for (i, (a, b)) in base.iter().zip(&themed).enumerate() {
if a != b {
differing += 1;
let pixel = (i / 4) as u32;
let (x, y) = (pixel % w, pixel / w);
assert!(
x < text_left && y < band_top,
"gutter face leaked outside the gutter region at ({x}, {y})"
);
}
}
assert!(differing > 20, "the digits must actually recolor");
}
#[test]
fn headless_band_text_faces_follow_content_class() {
// Acceptance 20: with the composed strings held constant,
// ui.statusline recolors a transient message, ui.minibuffer
// recolors a live minibuffer and an isearch band — and each
// face is a NO-OP on the content classes it doesn't own.
let (w, h) = (400u32, 300u32);
let Some(mut state) = headless_or_skip(w, h, "hello") else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
let statusline_face = vec![theme_face(
"ui.statusline",
CellStyle {
fg: CellColor::Rgb(240, 140, 40),
..CellStyle::default()
},
)];
let minibuffer_face = vec![theme_face(
"ui.minibuffer",
CellStyle {
fg: CellColor::Rgb(40, 220, 140),
..CellStyle::default()
},
)];
// (a) Buffer name showing: ui.statusline must not repaint it.
state.status_facts = Some(StatusFactsLocal {
buffer_id: bid,
name: "main.rs".into(),
modified: false,
diag_errors: 0,
diag_warnings: 0,
message: None,
});
let name_base = state.render_offscreen();
apply_faces(&mut state, statusline_face.clone());
assert_eq!(
name_base,
state.render_offscreen(),
"ui.statusline must not color the buffer-name class"
);
apply_faces(&mut state, Vec::new());
// (b) Transient message showing: ui.statusline recolors it.
state.status_facts = Some(StatusFactsLocal {
buffer_id: bid,
name: "main.rs".into(),
modified: false,
diag_errors: 0,
diag_warnings: 0,
message: Some("12 references".into()),
});
let msg_base = state.render_offscreen();
apply_faces(&mut state, statusline_face);
assert_ne!(
msg_base,
state.render_offscreen(),
"ui.statusline must recolor the transient message"
);
apply_faces(&mut state, Vec::new());
// (c) Live minibuffer: ui.minibuffer recolors the band text.
state.minibuffer = Some(MinibufferLocal {
prompt: "M-x ".into(),
input: "theme".into(),
cursor: 5,
candidates: Vec::new(),
selected: None,
total: 0,
});
let mb_base = state.render_offscreen();
apply_faces(&mut state, minibuffer_face.clone());
assert_ne!(
mb_base,
state.render_offscreen(),
"ui.minibuffer must recolor the live minibuffer"
);
apply_faces(&mut state, Vec::new());
state.minibuffer = None;
// (d) Isearch band: same face, same route.
state.search_prompt = Some(SearchPromptLocal {
buffer_id: bid,
query: "needle".into(),
active: Some(0),
total: 2,
regex: false,
invalid: false,
});
let isearch_base = state.render_offscreen();
apply_faces(&mut state, minibuffer_face);
assert_ne!(
isearch_base,
state.render_offscreen(),
"ui.minibuffer must recolor the isearch band"
);
}
#[test]
fn headless_candidate_face_recolors_the_dropdown_glyphs() {
// Acceptance 20 (round 3 finding 1): ui.minibuffer.candidate's
// GPU site is the dropdown's candidate glyph layer.
let (w, h) = (400u32, 300u32);
let Some(mut state) = headless_or_skip(w, h, "hello") else {
return;
};
state.minibuffer = Some(MinibufferLocal {
prompt: "M-x ".into(),
input: "the".into(),
cursor: 3,
candidates: vec!["theme-set".into(), "theme-clear".into()],
selected: Some(0),
total: 2,
});
let base = state.render_offscreen();
apply_faces(
&mut state,
vec![theme_face(
"ui.minibuffer.candidate",
CellStyle {
fg: CellColor::Rgb(250, 80, 160),
..CellStyle::default()
},
)],
);
assert_ne!(
base,
state.render_offscreen(),
"the candidate glyphs must recolor"
);
}
#[test]
fn headless_diag_face_recolors_band_counter_despite_unchanged_text() {
// Acceptance 22 — the round-1 finding-3 bite. The E: counter
// color is baked into shaped rich text and the composed string
// is CONSTANT here, so this fails without the ThemeFacts arm's
// shaping-cache invalidation (Q#TH8). The empty diag child is
// the built-in reset (Q#TH5): identical to unthemed.
let (w, h) = (400u32, 300u32);
let Some(mut state) = headless_or_skip(w, h, "alpha\nbeta\n") else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.view_range = (0, state.current_text.len() as u64);
state.status_facts = Some(StatusFactsLocal {
buffer_id: bid,
name: "main.rs".into(),
modified: false,
diag_errors: 2,
diag_warnings: 0,
message: None,
});
state.current_decorations.push(Decoration {
range: ByteRange { start: 0, end: 5 },
kind: DecorationKind::DiagnosticError,
});
let base = state.render_offscreen();
apply_faces(
&mut state,
vec![theme_face(
"ui.diag.error",
CellStyle {
fg: CellColor::Rgb(40, 200, 255),
..CellStyle::default()
},
)],
);
assert_ne!(
base,
state.render_offscreen(),
"the counter (and squiggle) must recolor with counts constant"
);
// An all-default diag child resets to the built-in color.
apply_faces(
&mut state,
vec![theme_face("ui.diag.error", CellStyle::default())],
);
assert_eq!(
base,
state.render_offscreen(),
"ui.diag.error = {{}} must render as the built-in severity color"
);
}
#[test]
fn headless_same_generation_summary_update_repaints_the_minimap() {
// PR #120 round 1 finding 1: theme recolors and diagnostic
// republishes ship a NEW summary at the SAME CRDT generation,
// and the minimap cache keys only on (generation, dims,
// scroll) — accepting a summary must drop the cached vertices
// or the stale strokes survive until an edit/resize/scroll.
let text = "alpha\nbeta\ngamma\ndelta\n";
let (w, h) = (400u32, 300u32);
let Some(mut state) = headless_or_skip(w, h, text) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.view_range = (0, text.len() as u64);
let summary = |color: CellColor| -> Vec<CellStyle> {
(0..4)
.map(|_| CellStyle {
fg: color,
..CellStyle::default()
})
.collect()
};
let _ = state.apply_attach_message(InstanceMessage::FileStyleSummary {
buffer_id: bid,
generation: 7,
lines: summary(CellColor::Rgb(200, 40, 40)),
});
let first = state.render_offscreen();
// Same generation, different colors — a theme-recolor twin.
let _ = state.apply_attach_message(InstanceMessage::FileStyleSummary {
buffer_id: bid,
generation: 7,
lines: summary(CellColor::Rgb(40, 200, 40)),
});
let second = state.render_offscreen();
assert_ne!(
first, second,
"a same-generation summary recolor must repaint the minimap"
);
}
#[test]
fn headless_snapshot_round_trip_summary_restores_the_minimap() {
// PR #120 round 2 finding 1 (frontend half): a `BufferSnapshot`
// drops `current_summary` with the rest of the buffer-scoped
// state, so after an A → B → A round trip no stale minimap
// may survive — and when the daemon's baseline reset re-ships
// the summary at the unchanged generation, the first visit's
// pixels must return exactly.
let text = "alpha\nbeta\ngamma\ndelta\n";
let (w, h) = (400u32, 300u32);
let Some(mut state) = headless_or_skip(w, h, text) else {
return;
};
// Both buffers carry the same text so the frames differ by
// the minimap alone.
let snapshot = || -> Vec<u8> {
let doc = loro::LoroDoc::new();
doc.get_text(LORO_TEXT_CONTAINER)
.insert(0, text)
.expect("insert snapshot text");
doc.export(loro::ExportMode::Snapshot)
.expect("export snapshot")
};
let bid_a = BufferId::next();
let bid_b = BufferId::next();
let visit = |state: &mut State, bid: BufferId| {
let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot {
buffer_id: bid,
crdt_snapshot: snapshot(),
});
state.view_range = (0, text.len() as u64);
};
let lines: Vec<CellStyle> = (0..4)
.map(|_| CellStyle {
fg: CellColor::Rgb(200, 40, 40),
..CellStyle::default()
})
.collect();
visit(&mut state, bid_a);
let _ = state.apply_attach_message(InstanceMessage::FileStyleSummary {
buffer_id: bid_a,
generation: 7,
lines: lines.clone(),
});
let first_visit = state.render_offscreen();
visit(&mut state, bid_b);
visit(&mut state, bid_a);
assert_ne!(
first_visit,
state.render_offscreen(),
"the round trip dropped the summary — no stale minimap"
);
let _ = state.apply_attach_message(InstanceMessage::FileStyleSummary {
buffer_id: bid_a,
generation: 7,
lines,
});
assert_eq!(
first_visit,
state.render_offscreen(),
"the re-shipped summary restores the first visit's minimap"
);
}
#[test]
fn own_wash_faces_color_local_rects_peers_keep_the_constant() {
// Acceptance 21 + 23: decode the emitted decoration vertex
// colors — the LOCAL selection and both search washes resolve
// their faces (site alpha preserved), while simultaneous PEER
// selection/current-line rects keep the hardcoded constants.
fn decode_quad_colors(bytes: &[u8]) -> Vec<[f32; 4]> {
bytes
.chunks_exact(24)
.map(|v| {
let f = |i: usize| {
f32::from_ne_bytes(v[i * 4..i * 4 + 4].try_into().expect("4 bytes"))
};
[f(2), f(3), f(4), f(5)]
})
.collect()
}
let text = "alpha\nbeta\ngamma\ndelta\n";
let Some(mut state) = headless_or_skip(400, 300, text) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.view_range = (0, text.len() as u64);
state.current_decorations.extend([
Decoration {
range: ByteRange { start: 0, end: 5 }, // "alpha"
kind: DecorationKind::Selection,
},
Decoration {
range: ByteRange { start: 6, end: 10 }, // "beta"
kind: DecorationKind::SearchMatch,
},
Decoration {
range: ByteRange { start: 11, end: 16 }, // "gamma"
kind: DecorationKind::SearchMatchActive,
},
]);
state.peer_presences.insert(
FrontendId(7),
PeerPresence {
buffer_id: bid,
cursor: 17,
selection: Some(SelectionSnapshot {
anchor: 17,
active: 22, // "delta"
}),
},
);
apply_faces(
&mut state,
vec![
theme_face(
"ui.selection",
CellStyle {
bg: CellColor::Rgb(9, 99, 199),
..CellStyle::default()
},
),
theme_face(
"ui.search.match",
CellStyle {
bg: CellColor::Rgb(10, 20, 30),
..CellStyle::default()
},
),
theme_face(
"ui.search.match.active",
CellStyle {
bg: CellColor::Rgb(40, 50, 60),
..CellStyle::default()
},
),
],
);
let colors = decode_quad_colors(&state.decoration_background_vertex_bytes());
// Exact float equality is deliberate: both sides derive from
// the same constants through the same arithmetic.
let has = |c: [f32; 4]| colors.contains(&c);
// Local rects: face RGB with each site's original alpha.
assert!(
has(glyphon_to_rgba(glyphon::Color::rgb(9, 99, 199), 0.30)),
"local selection must use ui.selection"
);
assert!(
has(glyphon_to_rgba(glyphon::Color::rgb(10, 20, 30), 0.30)),
"search match must use ui.search.match"
);
assert!(
has(glyphon_to_rgba(glyphon::Color::rgb(40, 50, 60), 0.48)),
"active match must use ui.search.match.active"
);
// Peer rects: the hardcoded constants, face table ignored.
assert!(
has([0.31, 0.42, 0.82, 0.30]),
"the peer selection must keep the Selection constant"
);
assert!(
has([0.55, 0.60, 0.75, 0.22]),
"the peer cursor line must keep the CurrentLine constant"
);
}
}