//! 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::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::{ BufferId, ByteRange, Decoration, DecorationKind, DecorationSegment, InstanceMessage, StyleSegment, StyleSpan, cell::Color as CellColor, }; use wgpu::MultisampleState; 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, }; /// 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, 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, /// 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, } 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 State { 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); // 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, buffer, current_text: initial_text.to_owned(), loro_doc: None, current_buffer_id: None, current_spans: Vec::new(), current_decorations: Vec::new(), } } /// Replace the rendered text with `text` and request a redraw. /// No-op 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 `current_spans`. When /// called from the `CrdtOp` path (text shifted under existing /// spans) the spans 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 `StyleSpans` frame catches up. A real artifact; /// classified as a session-4 known limitation rather than a bug. fn set_text(&mut self, text: &str) { if self.current_text == text { return; } self.current_text.clear(); self.current_text.push_str(text); self.reshape(); } /// 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). /// - `Goodbye` — surfaced via the reader thread's clean-EOF path, /// not handled here. /// /// Remaining `SemanticFrame` variants (`InlineAdornments`, /// `FileStyleSummary`) plus the grid variants (`CellDelta`, /// `Cursor`, `CursorByte`) and presence updates are ignored in /// session 5 — they land in subsequent Phase A sessions. 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.set_text(&text); 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. 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 } _ => None, } } /// `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`. Computes a sorted /// boundary list (every span and decoration edge, plus 0 and /// `text_len`) and emits one chunk per `[boundary_i, boundary_{i+1})` /// interval. Effective color picks the first matching decoration /// kind with a renderable color (diagnostics in session 5; the /// background-needing kinds — `Selection` / `SearchMatch` / /// `SearchMatchActive` / `CurrentLine` — produce `None` and fall /// through to span color). The decoration override means a /// diagnostic squiggle's color beats syntax color for the bytes /// it covers, which matches user expectation across both /// reference editors and the pmacs TUI. /// /// 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 text_len = self.current_text.len() as u64; // Collect every interesting byte position. Clamp to text_len // so a stale span/decoration past EOF (CrdtOp→next-frame race) // can't index out. let mut boundaries: Vec = vec![0, text_len]; for sp in &self.current_spans { boundaries.push(sp.range.start.min(text_len)); boundaries.push(sp.range.end.min(text_len)); } for d in &self.current_decorations { boundaries.push(d.range.start.min(text_len)); boundaries.push(d.range.end.min(text_len)); } boundaries.sort_unstable(); boundaries.dedup(); let mut chunks: Vec<(String, Attrs<'static>)> = Vec::new(); for w in boundaries.windows(2) { let (a, b) = (w[0], w[1]); if a >= b { continue; } // Pick the effective color at byte `a` (which is also the // color for every byte in `[a, b)` since boundaries // bracket every coverage change). let mut color: Option = None; for d in &self.current_decorations { if d.range.start <= a && a < d.range.end && let Some(c) = decoration_kind_to_color(d.kind) { color = Some(c); break; } } if color.is_none() { for sp in &self.current_spans { if sp.range.start <= a && a < sp.range.end { color = cell_color_to_glyphon(sp.style.fg); break; } } } let mut attrs = default_attrs.clone(); if let Some(c) = color { attrs = attrs.color(c); } chunks.push((self.current_text[a as usize..b as usize].to_owned(), attrs)); } // No spans / decorations + empty text ⇒ feed one empty chunk // so set_rich_text has something to draw. if chunks.is_empty() { chunks.push((String::new(), default_attrs.clone())); } 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(); } 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()); self.text_renderer .prepare( &self.device, &self.queue, &mut self.font_system, &mut self.atlas, &self.viewport, [TextArea { buffer: &self.buffer, left: 16.0, top: 16.0, scale: 1.0, bounds: TextBounds { left: 0, top: 0, right: self.config.width.cast_signed(), 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, }); self.text_renderer .render(&self.atlas, &self.viewport, &mut pass) .expect("text_renderer render"); } self.queue.submit(std::iter::once(encoder.finish())); frame.present(); self.atlas.trim(); } } /// 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`) accumulate in /// `current_decorations` but produce `None` here — they're recorded /// for the future quad-pipeline session. This is the rule-(iii) /// structural finding documented at session-5 framing: rendering /// backgrounds needs a wgpu quad pipeline + composition story, which /// is its own session, not absorbed into Phase A. /// /// 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 — deferred until quad pipeline. DecorationKind::Selection | DecorationKind::SearchMatch | DecorationKind::SearchMatchActive | DecorationKind::CurrentLine => None, } }