//! pmacs-gpu — GPU/GUI frontend for pmacs. //! //! Two run modes: //! //! - **Hello-world** (no `--attach` argument; session 2 default). //! Opens a window and renders "hello, pmacs" in the bundled //! `JetBrains` Mono. Used to confirm the wgpu/winit/glyphon stack //! without depending on a daemon. //! - **Attach** (`--attach `; session 3+). Connects //! to a running pmacs daemon, negotiates `semantic_render + //! crdt_replica`, imports the daemon's `BufferSnapshot` into a //! local loro replica, sends a `Viewport` back to request scoped //! styling, and consumes the `StyleSpans` stream — rendering the //! rope with per-span colors via cosmic-text's `set_rich_text`. //! Live `CrdtOp` updates apply to the doc; subsequent `StyleSpans` //! frames re-style. //! //! See `docs/pmacs-gpu-design.md` for the arc framing. Phase A's //! adversarial-verification framing applies from session 4 forward; //! findings classified per rule (iii) at surface-time. //! //! The bundled font is `JetBrains` Mono Regular, distributed under //! the SIL Open Font License 1.1 (see `fonts/OFL.txt`). mod attach; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use glyphon::{ Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, }; use pmacs_protocol::{ AdornmentContent, AdornmentPlacement, BufferId, ByteRange, Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, SelectionSnapshot, StyleSegment, StyleSpan, cell::{Color as CellColor, Style as CellStyle}, }; use wgpu::MultisampleState; use wgpu::util::DeviceExt; use winit::application::ApplicationHandler; use winit::event::{ElementState, KeyEvent, WindowEvent}; use winit::event_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; const TEXT_RIGHT_GAP: f32 = 10.0; const MINIMAP_WIDTH: f32 = 48.0; const MINIMAP_RIGHT: f32 = 12.0; const MINIMAP_TOP: f32 = 12.0; const MINIMAP_BOTTOM: f32 = 12.0; const MINIMAP_MIN_SURFACE_WIDTH: u32 = 180; const MINIMAP_MIN_THUMB_HEIGHT: f32 = 18.0; const MINIMAP_H_PAD: f32 = 3.0; const MINIMAP_CODE_COLS: f32 = 100.0; const MINIMAP_MIN_STROKE_WIDTH: f32 = 1.5; const MINIMAP_MAX_LINE_STROKE_HEIGHT: f32 = 2.0; const CODE_LINE_HEIGHT: f32 = 22.0; const MINIMAP_BG: [f32; 4] = [0.075, 0.075, 0.105, 0.92]; const MINIMAP_DEFAULT_LINE: [f32; 4] = [0.23, 0.23, 0.29, 0.82]; const MINIMAP_THUMB_FILL: [f32; 4] = [0.82, 0.82, 0.92, 0.18]; const MINIMAP_THUMB_BORDER: [f32; 4] = [0.86, 0.86, 0.96, 0.7]; const QUAD_SHADER: &str = r" struct VertexOut { @builtin(position) pos: vec4, @location(0) color: vec4, }; @vertex fn vs_main( @location(0) pos: vec2, @location(1) color: vec4, ) -> VertexOut { var out: VertexOut; out.pos = vec4(pos, 0.0, 1.0); out.color = color; return out; } @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { return in.color; } "; const QUAD_VERTEX_STRIDE: wgpu::BufferAddress = 24; const QUAD_VERTEX_ATTRS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4]; /// Text the hello-world (and attach-pre-snapshot / attach-failed) /// modes render. Once the daemon's `BufferSnapshot` arrives the /// rendered text becomes the rope contents instead. const HELLO_TEXT: &str = "hello, pmacs"; /// Container id the daemon uses on its loro `LoroDoc` for the /// buffer's text. Must match `pmacs::crdt::CrdtState`'s container /// name (`"body"`). const LORO_TEXT_CONTAINER: &str = "body"; /// Custom events delivered to the winit event loop. The reader thread /// in `attach.rs` forwards each decoded `InstanceMessage` through the /// `EventLoopProxy` it was handed by `connect()`; the main /// thread dispatches them in `user_event` below. #[derive(Debug)] pub enum AppEvent { /// A message or disconnect notification from the attach reader /// thread. Attach(AttachEvent), } /// CLI mode derived from argv. #[derive(Debug, Clone)] enum Mode { /// `pmacs-gpu` (no args): inert hello-world. HelloWorld, /// `pmacs-gpu --attach `: connect + render the daemon's /// rope. Attach { socket: PathBuf }, } fn main() { env_logger::init(); let mode = parse_args(std::env::args().skip(1).collect()); let event_loop = EventLoop::::with_user_event() .build() .expect("create winit event loop"); let proxy = event_loop.create_proxy(); let mut app = App { mode, proxy: Some(proxy), state: None, attach_client: None, }; event_loop .run_app(&mut app) .expect("winit event loop run_app"); } /// Tiny argv parser. No `clap` because the surface is genuinely two /// shapes; full CLI parsing arrives when there's more to parse. The /// `for` ranges over a small set: at most one `--attach ` or /// `--help` arrives, plus any stray unrecognized flag. fn parse_args(args: Vec) -> Mode { let mut iter = args.into_iter(); let Some(first) = iter.next() else { return Mode::HelloWorld; }; match first.as_str() { "--attach" => { let socket = iter.next().unwrap_or_else(|| { eprintln!("pmacs-gpu: --attach requires a socket path"); std::process::exit(2); }); Mode::Attach { socket: PathBuf::from(socket), } } "--help" | "-h" => { eprintln!( "pmacs-gpu — GPU/GUI frontend for pmacs\n\nUSAGE:\n pmacs-gpu \ hello-world (renders \"hello, pmacs\")\n pmacs-gpu --attach \ connect to a daemon's Unix socket and render its rope\n" ); std::process::exit(0); } other => { eprintln!("pmacs-gpu: unrecognized argument: {other}"); std::process::exit(2); } } } /// Top-level application handler. `state` is `Option` because winit /// 0.30 builds the window in `resumed()`, not at `main()` start; /// `attach_client` is held so the write half of the Unix stream /// stays alive for as long as the window does. struct App { mode: Mode, /// The event-loop proxy is taken in `resumed()` and handed to the /// reader thread. `Option` only because it can't be cloned out of /// a non-Option in a borrow. proxy: Option>, state: Option, /// Held both for stream lifetime and for the main loop's /// `send_viewport` write-back path. Session 4 uses this; later /// sessions will add cursor/edit/focus emissions. attach_client: Option, } /// All resources owned by one running pmacs-gpu instance. struct State { window: Arc, device: wgpu::Device, queue: wgpu::Queue, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, font_system: FontSystem, swash_cache: SwashCache, viewport: Viewport, atlas: TextAtlas, text_renderer: TextRenderer, quad_renderer: QuadRenderer, 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, /// Code-shape data derived from `current_text`, used to give the /// minimap horizontal structure even though `FileStyleSummary` /// carries only one dominant style per line. current_line_shapes: Vec, /// Local CRDT replica seeded by `BufferSnapshot`. `None` in /// hello-world mode or before the first snapshot arrives in /// attach mode. loro_doc: Option, /// Buffer the current rope text + spans interpret. Set when a /// `BufferSnapshot` arrives; used as the routing key for /// `StyleSpans` updates (drop those for other buffers). current_buffer_id: Option, /// Sorted-by-`range.start` styling spans for `current_buffer_id`. /// Replaced wholesale on `StyleSpans { full: true, .. }`; merged /// per the M11.4 dirty-segment rule on `full: false` (segments' /// ranges authoritatively replace styling within them; spans /// straddling a dirty edge get clipped to outside the dirty /// range). current_spans: Vec, /// Sorted-by-`range.start` decorations for `current_buffer_id`. /// Same M11.4 dirty-merge semantics as `current_spans`: `Decorations /// { full: true, .. }` replaces; `full: false` clips/replaces per /// segment range. /// /// Composition with `current_spans` in `reshape`: a decoration's /// color override beats the span's `style.fg` for the bytes it /// covers (semantic signal — a diagnostic — outranks syntactic /// signal). Decoration kinds whose visual is a background /// (`Selection`, `SearchMatch`, `SearchMatchActive`, `CurrentLine`) /// are not rendered in session 5; see the session-5 design note /// for the deferred quad-pipeline finding. current_decorations: Vec, /// Inline virtual text for `current_buffer_id` (session 6). /// Producer-side Phase A currently emits LSP inlay hints as /// `AtOffset` text adornments only. The GUI stores the whole scoped /// set and projects it into the shaped rich text without inserting /// bytes into `current_text`; source byte ranges for style spans and /// decorations therefore remain source-relative. current_adornments: Vec, /// Whole-file per-line dominant styles for the minimap (session 7). /// The daemon emits this summary on first frame and after CRDT /// generation changes. We keep the latest summary until a newer one /// arrives, matching the ownership rule used by style spans, /// decorations, and inline adornments. current_summary: Option, /// Peer presence (session 9.3), keyed by source frontend id. Each /// entry is one *other* attached frontend's cursor + selection, /// delivered via `InstanceMessage::PresenceUpdate`. A read-only /// mirror has no cursor of its own (no input path), so its own /// `Selection` / `CurrentLine` decorations are inert; the editing /// peer's presence is what the user actually watches. The quad- /// background path renders `Selection` / `CurrentLine` washes from /// these entries rather than from `current_decorations`. Sender /// exclusion at the daemon means our own id never appears here. peer_presences: HashMap, } /// One peer frontend's cursor + selection in a buffer, from /// `InstanceMessage::PresenceUpdate`. Byte offsets are in the buffer's /// coordinate space; the renderer maps them to glyph rectangles via /// the local layout and clamps to text length, so a presence that /// briefly lags an edit can never index out. #[derive(Clone, Copy, Debug)] struct PeerPresence { buffer_id: BufferId, cursor: u64, selection: Option, } struct QuadRenderer { pipeline: wgpu::RenderPipeline, } #[derive(Clone, Debug)] struct FileStyleSummaryState { generation: u64, lines: Vec, } impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.state.is_some() { return; } let initial_text = match &self.mode { Mode::HelloWorld => HELLO_TEXT, Mode::Attach { .. } => "(connecting...)", }; self.state = Some(State::new(event_loop, initial_text)); // In attach mode, kick off the connection now that the event // loop is running and a proxy is available. Failure logs and // leaves the window showing its `(connecting...)` placeholder // — better UX than killing the window during dev. if let Mode::Attach { socket } = self.mode.clone() { let proxy = self.proxy.take().expect("proxy taken twice"); match attach::connect(&socket, proxy) { Ok(client) => { self.attach_client = Some(client); } Err(e) => { eprintln!("pmacs-gpu: attach failed: {e}"); if let Some(state) = self.state.as_mut() { state.set_text("(attach failed; see stderr)"); } } } } } fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { let Some(state) = self.state.as_mut() else { return; }; match event { WindowEvent::CloseRequested | WindowEvent::KeyboardInput { event: KeyEvent { logical_key: Key::Named(NamedKey::Escape), state: ElementState::Pressed, .. }, .. } => event_loop.exit(), WindowEvent::Resized(size) => state.resize(size.width.max(1), size.height.max(1)), WindowEvent::RedrawRequested => state.render(), _ => {} } } 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 follow_up = state.apply_attach_message(*msg); // 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}"); } } 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, } 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); } } impl State { #[allow(clippy::too_many_lines)] // linear GPU/font/surface setup; splitting would obscure ordering. fn new(event_loop: &ActiveEventLoop, initial_text: &str) -> Self { let window = Arc::new( event_loop .create_window( Window::default_attributes() .with_title("pmacs-gpu") .with_inner_size(winit::dpi::LogicalSize::new( f64::from(INITIAL_WIDTH), f64::from(INITIAL_HEIGHT), )), ) .expect("create window"), ); let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); let surface = instance .create_surface(window.clone()) .expect("create surface"); let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::LowPower, compatible_surface: Some(&surface), force_fallback_adapter: false, })) .expect("request_adapter"); let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("pmacs-gpu device"), required_features: wgpu::Features::empty(), required_limits: wgpu::Limits::default(), ..wgpu::DeviceDescriptor::default() })) .expect("request_device"); let inner_size = window.inner_size(); let surface_caps = surface.get_capabilities(&adapter); let surface_format = surface_caps .formats .iter() .copied() .find(wgpu::TextureFormat::is_srgb) .unwrap_or(surface_caps.formats[0]); let config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format: surface_format, width: inner_size.width.max(1), height: inner_size.height.max(1), present_mode: wgpu::PresentMode::Fifo, desired_maximum_frame_latency: 2, alpha_mode: surface_caps.alpha_modes[0], view_formats: vec![], }; surface.configure(&device, &config); let mut font_system = FontSystem::new(); font_system.db_mut().load_font_data(JETBRAINS_MONO.to_vec()); let swash_cache = SwashCache::new(); let cache = Cache::new(&device); let mut viewport = Viewport::new(&device, &cache); viewport.update( &queue, Resolution { width: config.width, height: config.height, }, ); let mut atlas = TextAtlas::new(&device, &queue, &cache, surface_format); let text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); let quad_renderer = QuadRenderer::new(&device, surface_format); // Smaller font in attach mode (file contents tend to be more // than one line); larger only fits "hello, pmacs"-shaped // strings. Picked metrics that look reasonable for code at // 800px wide. let mut buffer = Buffer::new(&mut font_system, Metrics::new(16.0, 22.0)); buffer.set_size( &mut font_system, Some(config.width as f32), Some(config.height as f32), ); 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); Self { window, device, queue, surface, config, font_system, swash_cache, viewport, atlas, text_renderer, quad_renderer, buffer, current_text: initial_text.to_owned(), current_line_shapes: minimap_line_shapes(initial_text), loro_doc: None, current_buffer_id: None, current_spans: Vec::new(), current_decorations: Vec::new(), current_adornments: Vec::new(), current_summary: None, peer_presences: HashMap::new(), } } /// 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); self.current_line_shapes = minimap_line_shapes(text); 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 /// re-extracted. /// - `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. #[allow(clippy::too_many_lines)] // per-variant match dispatcher; one arm per InstanceMessage. fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option { match msg { InstanceMessage::BufferSnapshot { buffer_id, crdt_snapshot, } => { let doc = loro::LoroDoc::new(); if let Err(e) = doc.import(&crdt_snapshot) { eprintln!("pmacs-gpu: BufferSnapshot import failed: {e:?}"); return None; } let text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); let text_len = text.len() as u64; self.loro_doc = Some(doc); 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 are anchored in the prior buffer's // coordinate space; drop them so a stale offset can't // paint against the new rope before the next // PresenceUpdate arrives. self.peer_presences.clear(); if !self.set_text(&text) { self.reshape(); } Some(ViewportSend { buffer_id, visible: ByteRange { start: 0, end: text_len, }, generation: 0, }) } 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; }; if let Err(e) = doc.import(&op.bytes) { 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 text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); self.set_text(&text); None } InstanceMessage::StyleSpans { buffer_id, generation: _, full, segments, } => { if self.current_buffer_id != Some(buffer_id) { return None; } if full { self.replace_style_spans(segments); } else { self.merge_style_spans(segments); } self.reshape(); None } InstanceMessage::Decorations { buffer_id, generation: _, full, segments, } => { if self.current_buffer_id != Some(buffer_id) { return None; } if full { self.replace_decorations(segments); } else { self.merge_decorations(segments); } self.reshape(); 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.reshape(); None } InstanceMessage::FileStyleSummary { buffer_id, generation, lines, } => { self.apply_file_style_summary(buffer_id, generation, lines); None } // Session 9.3 — peer presence. The editing frontend's // cursor + selection drive the `CurrentLine` / `Selection` // washes for this read-only mirror (finding QB1). Store // per source frontend; a redraw recomputes the background // rects from `peer_presences`. We never receive our own // (daemon sender exclusion). InstanceMessage::PresenceUpdate { frontend_id, buffer_id, cursor, selection, } => { // Run with `PMACS_GPU_DEBUG_PRESENCE=1` to confirm peer // presence is arriving and routed to the right buffer. // A `buf != current` line means the peer is on a buffer // this mirror isn't displaying (no wash expected); no // line at all means the message isn't reaching us. if debug_presence() { eprintln!( "pmacs-gpu presence: fid={frontend_id:?} buf={buffer_id:?} \ current={:?} cursor={cursor} sel={selection:?}", self.current_buffer_id ); } self.peer_presences.insert( frontend_id, PeerPresence { buffer_id, cursor, selection, }, ); self.window.request_redraw(); None } _ => None, } } fn apply_file_style_summary( &mut self, buffer_id: BufferId, generation: u64, lines: Vec, ) { if self.current_buffer_id != Some(buffer_id) { return; } if self .current_summary .as_ref() .is_some_and(|summary| generation < summary.generation) { return; } self.current_summary = Some(FileStyleSummaryState { generation, lines }); self.window.request_redraw(); } /// `full = true` path: discard prior styling, take the segments' /// spans as authoritative for the declared viewport. fn replace_style_spans(&mut self, segments: Vec) { self.current_spans.clear(); for seg in segments { self.current_spans.extend(seg.spans); } self.current_spans.sort_by_key(|s| s.range.start); } /// `full = false` path: each segment's `range` authoritatively /// replaces styling within it. Spans fully inside any dirty range /// drop; spans straddling a dirty edge get clipped to outside the /// range; the new spans are appended; finally everything sorts. /// /// This is exactly the surface bet #1 from the framing pass /// predicted ("dirty-segment edges at viewport boundaries — /// headless-test-blind-spot probe"). Per-byte adversarial /// behavior here lives in the user-side validation, not in unit /// tests — that's the design-doc framing's whole point. fn merge_style_spans(&mut self, segments: Vec) { for seg in &segments { let dirty = seg.range; let mut kept = Vec::with_capacity(self.current_spans.len()); for sp in self.current_spans.drain(..) { if sp.range.end <= dirty.start || sp.range.start >= dirty.end { // Outside the dirty range entirely — keep as-is. kept.push(sp); } else if sp.range.start < dirty.start && sp.range.end > dirty.end { // Straddles both edges: split into two clipped halves. kept.push(StyleSpan { range: ByteRange { start: sp.range.start, end: dirty.start, }, style: sp.style, }); kept.push(StyleSpan { range: ByteRange { start: dirty.end, end: sp.range.end, }, style: sp.style, }); } else if sp.range.start < dirty.start { // Straddles the left edge only — clip to the left. kept.push(StyleSpan { range: ByteRange { start: sp.range.start, end: dirty.start, }, style: sp.style, }); } else if sp.range.end > dirty.end { // Straddles the right edge only — clip to the right. kept.push(StyleSpan { range: ByteRange { start: dirty.end, end: sp.range.end, }, style: sp.style, }); } // else: fully inside the dirty range ⇒ drop. } self.current_spans = kept; } for seg in segments { self.current_spans.extend(seg.spans); } self.current_spans.sort_by_key(|s| s.range.start); } /// `Decorations { full: true, .. }` path — exactly the /// `replace_style_spans` shape for decorations. The wire structure /// is intentionally symmetric (`DecorationSegment` ↔ `StyleSegment`). fn replace_decorations(&mut self, segments: Vec) { self.current_decorations.clear(); for seg in segments { self.current_decorations.extend(seg.decorations); } self.current_decorations.sort_by_key(|d| d.range.start); } /// `Decorations { full: false, .. }` path — M11.4 dirty-merge for /// decorations. Structurally identical to [`Self::merge_style_spans`] /// — same edge-clip/drop/split logic, same trailing append + /// re-sort. /// /// **Recorded session-5 finding (rule iii, deferred):** this /// duplication of the M11.4 merge algorithm across two /// `(range, T)`-shaped types invites a generic /// `merge_dirty_segments` helper. The refactor is /// minor in lines but touches a load-bearing invariant; deferring /// until at least a third instance arrives (e.g. peer-cursor /// decorations from `PresenceUpdate`) so the abstraction is /// inducted from three points rather than two. fn merge_decorations(&mut self, segments: Vec) { for seg in &segments { let dirty = seg.range; let mut kept = Vec::with_capacity(self.current_decorations.len()); for d in self.current_decorations.drain(..) { if d.range.end <= dirty.start || d.range.start >= dirty.end { kept.push(d); } else if d.range.start < dirty.start && d.range.end > dirty.end { kept.push(Decoration { range: ByteRange { start: d.range.start, end: dirty.start, }, kind: d.kind, }); kept.push(Decoration { range: ByteRange { start: dirty.end, end: d.range.end, }, kind: d.kind, }); } else if d.range.start < dirty.start { kept.push(Decoration { range: ByteRange { start: d.range.start, end: dirty.start, }, kind: d.kind, }); } else if d.range.end > dirty.end { kept.push(Decoration { range: ByteRange { start: dirty.end, end: d.range.end, }, kind: d.kind, }); } } self.current_decorations = kept; } for seg in segments { self.current_decorations.extend(seg.decorations); } self.current_decorations.sort_by_key(|d| d.range.start); } /// Re-build the cosmic-text Buffer from `current_text` + /// `current_spans` + `current_decorations` + /// `current_adornments`. Source styling/decorations remain /// byte-indexed into `current_text`; adornments contribute extra /// rich-text chunks at their anchors without mutating the source /// string. That display projection is the central session-6 /// invariant: virtual text must not shift the source-byte ranges /// used by `StyleSpans` / `Decorations`. /// /// Complexity is O(B × (S + D)) per reshape where B is the boundary /// count and S+D is spans+decorations. For viewport-scoped data /// this is bounded by visible bytes. A sweep-line refactor with /// active-set pointers is the obvious upgrade if reshape cost /// surfaces in profile data — recorded but not done in session 5. fn reshape(&mut self) { let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono")); let chunks: Vec<(String, Attrs<'static>)> = projected_rich_chunks( &self.current_text, &self.current_spans, &self.current_decorations, &self.current_adornments, ) .into_iter() .map(|chunk| { let mut attrs = default_attrs.clone(); if let Some(c) = chunk.color { attrs = attrs.color(c); } (chunk.text, attrs) }) .collect(); self.buffer.set_rich_text( &mut self.font_system, chunks.iter().map(|(s, a)| (s.as_str(), a.clone())), &default_attrs, Shaping::Advanced, None, ); self.buffer.shape_until_scroll(&mut self.font_system, false); self.window.request_redraw(); } fn resize(&mut self, width: u32, height: u32) { self.config.width = width; self.config.height = height; self.surface.configure(&self.device, &self.config); self.viewport .update(&self.queue, Resolution { width, height }); self.buffer.set_size( &mut self.font_system, Some(width as f32), Some(height as f32), ); self.window.request_redraw(); } #[allow(clippy::too_many_lines)] // linear per-frame GPU sequence + optional timing. fn render(&mut self) { let frame = match self.surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(frame) | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { self.surface.configure(&self.device, &self.config); return; } wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => return, wgpu::CurrentSurfaceTexture::Validation => { eprintln!("surface acquisition raised a validation error"); return; } }; let view = frame .texture .create_view(&wgpu::TextureViewDescriptor::default()); let frame_start = debug_frame().then(std::time::Instant::now); let bg_vertices = self.decoration_background_vertex_bytes(); let bg_vertex_count = (bg_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let bg_buffer = (!bg_vertices.is_empty()).then(|| { self.device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("pmacs-gpu decoration backgrounds"), contents: &bg_vertices, usage: wgpu::BufferUsages::VERTEX, }) }); let after_bg = debug_frame().then(std::time::Instant::now); let minimap_vertices = self.minimap_vertex_bytes(); let minimap_vertex_count = (minimap_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let minimap_buffer = (!minimap_vertices.is_empty()).then(|| { self.device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("pmacs-gpu minimap vertices"), contents: &minimap_vertices, usage: wgpu::BufferUsages::VERTEX, }) }); let after_minimap = debug_frame().then(std::time::Instant::now); let text_bounds_right = self.text_bounds_right(); self.text_renderer .prepare( &self.device, &self.queue, &mut self.font_system, &mut self.atlas, &self.viewport, [TextArea { buffer: &self.buffer, left: TEXT_LEFT, top: TEXT_TOP, scale: 1.0, bounds: TextBounds { left: 0, top: 0, right: text_bounds_right, bottom: self.config.height.cast_signed(), }, default_color: Color::rgb(230, 230, 235), custom_glyphs: &[], }], &mut self.swash_cache, ) .expect("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: &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); } self.text_renderer .render(&self.atlas, &self.viewport, &mut pass) .expect("text_renderer render"); if let Some(vertex_buffer) = minimap_buffer.as_ref() { self.quad_renderer .render(&mut pass, vertex_buffer, minimap_vertex_count); } } self.queue.submit(std::iter::once(encoder.finish())); frame.present(); 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 { 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, 0, visible_lines, ); rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } /// Vertex bytes for quad-pipeline background rectangles. Session /// 9.3 sources `CurrentLine` / `Selection` washes from peer /// presence (the editing frontend's cursor + selection) rather /// than from `current_decorations`: this is a read-only mirror, so /// its own per-window `Selection` / `CurrentLine` decorations are /// inert (cursor pinned at 0, no selection). See finding QB1 in /// `docs/pmacs-gpu-quad-backgrounds-framing.md`. fn decoration_background_vertex_bytes(&self) -> Vec { let rects = self.peer_background_rects(); rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } /// Background rectangles for every peer's cursor line + selection /// in the current buffer. `CurrentLine` covers the source line /// holding the peer cursor; `Selection` covers the peer's selected /// byte range. Both map byte ranges to per-visual-line glyph /// extents via `peer_glyph_extent_rects`. Single-peer mirrors reuse /// the `Selection` / `CurrentLine` colors so the visual reads as /// "my editing, mirrored"; per-peer distinct colors are deferred. fn peer_background_rects(&self) -> Vec { let Some(buffer_id) = self.current_buffer_id else { return Vec::new(); }; let text_len = self.current_text.len() as u64; // Buffer-absolute byte offset of each `\n`-delimited line, // indexed by `LayoutRun::line_i`. `LayoutGlyph::{start,end}` are // offsets within the *original line*, not the whole buffer, so // every byte range below must be rebased per line before it can // be matched against glyph offsets. let line_offsets = line_byte_offsets(&self.current_text); let mut rects = Vec::new(); for presence in self.peer_presences.values() { if presence.buffer_id != buffer_id { continue; } // CurrentLine: the source line containing the peer cursor. if let Some(color) = decoration_kind_to_bg_color(DecorationKind::CurrentLine) { let (lo, hi) = source_line_range(&self.current_text, presence.cursor); self.push_glyph_extent_rects(&mut rects, &line_offsets, lo, hi, color); } // Selection: the peer's selected byte range, normalized. if let Some(sel) = presence.selection && let Some(color) = decoration_kind_to_bg_color(DecorationKind::Selection) { let lo = sel.anchor.min(sel.active).min(text_len); let hi = sel.anchor.max(sel.active).min(text_len); if hi > lo { self.push_glyph_extent_rects(&mut rects, &line_offsets, lo, hi, color); } } } rects } /// 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, line_offsets: &[u64], lo: u64, hi: u64, color: [f32; 4], ) { if hi <= lo { return; } 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 = None; let mut max_x: Option = 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 { rects.push(MinimapRect { x: TEXT_LEFT + x0, y: TEXT_TOP + run.line_top, w: x1 - x0, h: run.line_height, 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)] struct RichChunk { text: String, color: Option, } fn minimap_left(surface_width: u32) -> Option { 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) } fn estimated_visible_lines(surface_height: u32) -> usize { ((surface_height as f32 - TEXT_TOP.max(0.0)) / CODE_LINE_HEIGHT) .ceil() .max(1.0) as usize } fn minimap_rects( lines: &[CellStyle], shapes: &[MinimapLineShape], surface_width: u32, surface_height: u32, first_visible_line: usize, visible_lines: usize, ) -> Vec { let Some(x) = minimap_left(surface_width) else { return Vec::new(); }; if lines.is_empty() || surface_height as f32 <= MINIMAP_TOP + MINIMAP_BOTTOM { return Vec::new(); } let height = surface_height as f32 - MINIMAP_TOP - MINIMAP_BOTTOM; 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, 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, 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 { 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 { 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] { match style.fg { 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 = 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 = std::sync::OnceLock::new(); *FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_FRAME").is_some()) } /// 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 { let mut starts = vec![0u64]; for (i, b) in text.bytes().enumerate() { if b == b'\n' { starts.push(i as u64 + 1); } } starts } /// 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 { 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, 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, 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()); } } 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. fn projected_rich_chunks( text: &str, spans: &[StyleSpan], decorations: &[Decoration], adornments: &[InlineAdornment], ) -> Vec { let text_len = text.len() as u64; let mut boundaries: Vec = vec![0, text_len]; for sp in spans { boundaries.push(sp.range.start.min(text_len)); boundaries.push(sp.range.end.min(text_len)); } for d in decorations { boundaries.push(d.range.start.min(text_len)); boundaries.push(d.range.end.min(text_len)); } 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(*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, decorations), }); } } push_adornments_at( &mut chunks, &renderable_adornments, &mut adorn_idx, text_len, ); if chunks.is_empty() { chunks.push(RichChunk { text: String::new(), color: None, }); } chunks } fn renderable_adornment_anchor(adornment: &InlineAdornment, text_len: u64) -> Option { 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, 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)), }); } *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], decorations: &[Decoration], ) -> Option { for d in decorations { if d.range.start <= byte && byte < d.range.end && let Some(c) = decoration_kind_to_color(d.kind) { return Some(c); } } 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 { 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)), } } /// 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) } /// Map a [`DecorationKind`] to a foreground color override, or `None` /// for kinds whose visual is a background and can't be expressed in /// the current `Attrs`-only rendering pipeline. /// /// Session 5 ships **fg-only** decoration rendering. The four /// background-needing kinds (`Selection`, `SearchMatch`, /// `SearchMatchActive`, `CurrentLine`) return `None` here because the /// glyph-color path can only render foregrounds; they route through /// [`decoration_kind_to_bg_color`] and the quad pipeline instead. /// /// Color choices match the conventional editor palette (red errors, /// yellow warnings, light blue info, dim hints) so the GPU window's /// visual matches what the pmacs TUI paints via terminal color codes. fn decoration_kind_to_color(kind: DecorationKind) -> Option { match kind { // ANSI bright red — matches TUI diagnostic-error palette. DecorationKind::DiagnosticError => Some(glyphon::Color::rgb(241, 76, 76)), // ANSI bright yellow. DecorationKind::DiagnosticWarning => Some(glyphon::Color::rgb(245, 245, 67)), // ANSI bright blue. DecorationKind::DiagnosticInfo => Some(glyphon::Color::rgb(59, 142, 234)), // ANSI bright black (dim gray — hints should be visible but // visually quietest of the diagnostic four). DecorationKind::DiagnosticHint => Some(glyphon::Color::rgb(102, 102, 102)), // Background-needing kinds route through the quad pipeline. DecorationKind::Selection | DecorationKind::SearchMatch | DecorationKind::SearchMatchActive | DecorationKind::CurrentLine => None, } } /// Background-bearing companion to [`decoration_kind_to_color`]: maps /// each background-needing `DecorationKind` to its quad-pipeline color /// as an RGBA tuple in 0..=1 space. Returns `None` for foreground-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]), // Deferred to the search-feature arc. DecorationKind::SearchMatch | DecorationKind::SearchMatchActive => None, // Foreground-only — handled by [`decoration_kind_to_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::from_ne_bytes( bytes[start..start + std::mem::size_of::()] .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 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 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_and_current_line() { // 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()); // Search-feature arc — still deferred. assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatch).is_none()); assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatchActive).is_none()); // Foreground-only kinds belong to the fg helper. for kind in [ DecorationKind::DiagnosticError, DecorationKind::DiagnosticWarning, DecorationKind::DiagnosticInfo, DecorationKind::DiagnosticHint, ] { assert!(decoration_kind_to_bg_color(kind).is_none()); assert!(decoration_kind_to_color(kind).is_some()); } } #[test] fn fg_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 fg = decoration_kind_to_color(kind).is_some(); let bg = decoration_kind_to_bg_color(kind).is_some(); // Background helper returns None for the search pair — // deferred to the search-feature arc. For both of those, // decoration_kind_to_color is also None. That is the // "neither yet" state — the exclusive-or test exempts it. let deferred = matches!( kind, DecorationKind::SearchMatch | DecorationKind::SearchMatchActive ); assert!( deferred || (fg ^ bg), "{kind:?}: fg={fg} bg={bg} — should be exactly one (unless deferred)" ); } } #[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 inline_adornment_does_not_shift_source_decoration_ranges() { let chunks = projected_rich_chunks( "abcd", &[], &[Decoration { range: ByteRange { start: 2, end: 4 }, kind: DecorationKind::DiagnosticError, }], &[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(), "diagnostic fg override 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_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); } }