pmacs/pmacs-gpu/src/main.rs

2039 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

//! pmacs-gpu — GPU/GUI frontend for pmacs.
//!
//! Two run modes:
//!
//! - **Hello-world** (no `--attach` argument; session 2 default).
//! Opens a window and renders "hello, pmacs" in the bundled
//! `JetBrains` Mono. Used to confirm the wgpu/winit/glyphon stack
//! without depending on a daemon.
//! - **Attach** (`--attach <unix-socket-path>`; session 3+). Connects
//! to a running pmacs daemon, negotiates `semantic_render +
//! crdt_replica`, imports the daemon's `BufferSnapshot` into a
//! local loro replica, sends a `Viewport` back to request scoped
//! styling, and consumes the `StyleSpans` stream — rendering the
//! rope with per-span colors via cosmic-text's `set_rich_text`.
//! Live `CrdtOp` updates apply to the doc; subsequent `StyleSpans`
//! frames re-style.
//!
//! See `docs/pmacs-gpu-design.md` for the arc framing. Phase A's
//! adversarial-verification framing applies from session 4 forward;
//! findings classified per rule (iii) at surface-time.
//!
//! The bundled font is `JetBrains` Mono Regular, distributed under
//! the SIL Open Font License 1.1 (see `fonts/OFL.txt`).
mod attach;
use std::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, InlineAdornment, InstanceMessage, 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<f32>,
@location(0) color: vec4<f32>,
};
@vertex
fn vs_main(
@location(0) pos: vec2<f32>,
@location(1) color: vec4<f32>,
) -> VertexOut {
var out: VertexOut;
out.pos = vec4<f32>(pos, 0.0, 1.0);
out.color = color;
return out;
}
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
return in.color;
}
";
const QUAD_VERTEX_STRIDE: wgpu::BufferAddress = 24;
const QUAD_VERTEX_ATTRS: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4];
/// Text the hello-world (and attach-pre-snapshot / attach-failed)
/// modes render. Once the daemon's `BufferSnapshot` arrives the
/// rendered text becomes the rope contents instead.
const HELLO_TEXT: &str = "hello, pmacs";
/// Container id the daemon uses on its loro `LoroDoc` for the
/// buffer's text. Must match `pmacs::crdt::CrdtState`'s container
/// name (`"body"`).
const LORO_TEXT_CONTAINER: &str = "body";
/// Custom events delivered to the winit event loop. The reader thread
/// in `attach.rs` forwards each decoded `InstanceMessage` through the
/// `EventLoopProxy<AppEvent>` it was handed by `connect()`; the main
/// thread dispatches them in `user_event` below.
#[derive(Debug)]
pub enum AppEvent {
/// A message or disconnect notification from the attach reader
/// thread.
Attach(AttachEvent),
}
/// CLI mode derived from argv.
#[derive(Debug, Clone)]
enum Mode {
/// `pmacs-gpu` (no args): inert hello-world.
HelloWorld,
/// `pmacs-gpu --attach <socket>`: connect + render the daemon's
/// rope.
Attach { socket: PathBuf },
}
fn main() {
env_logger::init();
let mode = parse_args(std::env::args().skip(1).collect());
let event_loop = EventLoop::<AppEvent>::with_user_event()
.build()
.expect("create winit event loop");
let proxy = event_loop.create_proxy();
let mut app = App {
mode,
proxy: Some(proxy),
state: None,
attach_client: None,
};
event_loop
.run_app(&mut app)
.expect("winit event loop run_app");
}
/// Tiny argv parser. No `clap` because the surface is genuinely two
/// shapes; full CLI parsing arrives when there's more to parse. The
/// `for` ranges over a small set: at most one `--attach <socket>` or
/// `--help` arrives, plus any stray unrecognized flag.
fn parse_args(args: Vec<String>) -> Mode {
let mut iter = args.into_iter();
let Some(first) = iter.next() else {
return Mode::HelloWorld;
};
match first.as_str() {
"--attach" => {
let socket = iter.next().unwrap_or_else(|| {
eprintln!("pmacs-gpu: --attach requires a socket path");
std::process::exit(2);
});
Mode::Attach {
socket: PathBuf::from(socket),
}
}
"--help" | "-h" => {
eprintln!(
"pmacs-gpu — GPU/GUI frontend for pmacs\n\nUSAGE:\n pmacs-gpu \
hello-world (renders \"hello, pmacs\")\n pmacs-gpu --attach <socket> \
connect to a daemon's Unix socket and render its rope\n"
);
std::process::exit(0);
}
other => {
eprintln!("pmacs-gpu: unrecognized argument: {other}");
std::process::exit(2);
}
}
}
/// Top-level application handler. `state` is `Option` because winit
/// 0.30 builds the window in `resumed()`, not at `main()` start;
/// `attach_client` is held so the write half of the Unix stream
/// stays alive for as long as the window does.
struct App {
mode: Mode,
/// The event-loop proxy is taken in `resumed()` and handed to the
/// reader thread. `Option` only because it can't be cloned out of
/// a non-Option in a borrow.
proxy: Option<winit::event_loop::EventLoopProxy<AppEvent>>,
state: Option<State>,
/// Held both for stream lifetime and for the main loop's
/// `send_viewport` write-back path. Session 4 uses this; later
/// sessions will add cursor/edit/focus emissions.
attach_client: Option<AttachClient>,
}
/// All resources owned by one running pmacs-gpu instance.
struct State {
window: Arc<Window>,
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<MinimapLineShape>,
/// Local CRDT replica seeded by `BufferSnapshot`. `None` in
/// hello-world mode or before the first snapshot arrives in
/// attach mode.
loro_doc: Option<loro::LoroDoc>,
/// Buffer the current rope text + spans interpret. Set when a
/// `BufferSnapshot` arrives; used as the routing key for
/// `StyleSpans` updates (drop those for other buffers).
current_buffer_id: Option<BufferId>,
/// Sorted-by-`range.start` styling spans for `current_buffer_id`.
/// Replaced wholesale on `StyleSpans { full: true, .. }`; merged
/// per the M11.4 dirty-segment rule on `full: false` (segments'
/// ranges authoritatively replace styling within them; spans
/// straddling a dirty edge get clipped to outside the dirty
/// range).
current_spans: Vec<StyleSpan>,
/// Sorted-by-`range.start` decorations for `current_buffer_id`.
/// Same M11.4 dirty-merge semantics as `current_spans`: `Decorations
/// { full: true, .. }` replaces; `full: false` clips/replaces per
/// segment range.
///
/// Composition with `current_spans` in `reshape`: a decoration's
/// color override beats the span's `style.fg` for the bytes it
/// covers (semantic signal — a diagnostic — outranks syntactic
/// signal). Decoration kinds whose visual is a background
/// (`Selection`, `SearchMatch`, `SearchMatchActive`, `CurrentLine`)
/// are not rendered in session 5; see the session-5 design note
/// for the deferred quad-pipeline finding.
current_decorations: Vec<Decoration>,
/// Inline virtual text for `current_buffer_id` (session 6).
/// Producer-side Phase A currently emits LSP inlay hints as
/// `AtOffset` text adornments only. The GUI stores the whole scoped
/// set and projects it into the shaped rich text without inserting
/// bytes into `current_text`; source byte ranges for style spans and
/// decorations therefore remain source-relative.
current_adornments: Vec<InlineAdornment>,
/// Whole-file per-line dominant styles for the minimap (session 7).
/// The daemon emits this summary on first frame and after CRDT
/// generation changes. We keep the latest summary until a newer one
/// arrives, matching the ownership rule used by style spans,
/// decorations, and inline adornments.
current_summary: Option<FileStyleSummaryState>,
}
struct QuadRenderer {
pipeline: wgpu::RenderPipeline,
}
#[derive(Clone, Debug)]
struct FileStyleSummaryState {
generation: u64,
lines: Vec<CellStyle>,
}
impl ApplicationHandler<AppEvent> for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let initial_text = match &self.mode {
Mode::HelloWorld => HELLO_TEXT,
Mode::Attach { .. } => "(connecting...)",
};
self.state = Some(State::new(event_loop, initial_text));
// In attach mode, kick off the connection now that the event
// loop is running and a proxy is available. Failure logs and
// leaves the window showing its `(connecting...)` placeholder
// — better UX than killing the window during dev.
if let Mode::Attach { socket } = self.mode.clone() {
let proxy = self.proxy.take().expect("proxy taken twice");
match attach::connect(&socket, proxy) {
Ok(client) => {
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 {
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,
}
}
/// 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.
///
/// Remaining semantic variants plus the grid variants (`CellDelta`,
/// `Cursor`, `CursorByte`) and presence updates are ignored in
/// session 7 — they land in subsequent Phase A sessions.
fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option<ViewportSend> {
match msg {
InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot,
} => {
let doc = loro::LoroDoc::new();
if let 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;
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
}
_ => None,
}
}
fn apply_file_style_summary(
&mut self,
buffer_id: BufferId,
generation: u64,
lines: Vec<CellStyle>,
) {
if self.current_buffer_id != Some(buffer_id) {
return;
}
if self
.current_summary
.as_ref()
.is_some_and(|summary| generation < summary.generation)
{
return;
}
self.current_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<StyleSegment>) {
self.current_spans.clear();
for seg in segments {
self.current_spans.extend(seg.spans);
}
self.current_spans.sort_by_key(|s| s.range.start);
}
/// `full = false` path: each segment's `range` authoritatively
/// replaces styling within it. Spans fully inside any dirty range
/// drop; spans straddling a dirty edge get clipped to outside the
/// range; the new spans are appended; finally everything sorts.
///
/// This is exactly the surface bet #1 from the framing pass
/// predicted ("dirty-segment edges at viewport boundaries —
/// headless-test-blind-spot probe"). Per-byte adversarial
/// behavior here lives in the user-side validation, not in unit
/// tests — that's the design-doc framing's whole point.
fn merge_style_spans(&mut self, segments: Vec<StyleSegment>) {
for seg in &segments {
let dirty = seg.range;
let mut kept = Vec::with_capacity(self.current_spans.len());
for sp in self.current_spans.drain(..) {
if sp.range.end <= dirty.start || sp.range.start >= dirty.end {
// Outside the dirty range entirely — keep as-is.
kept.push(sp);
} else if sp.range.start < dirty.start && sp.range.end > dirty.end {
// Straddles both edges: split into two clipped halves.
kept.push(StyleSpan {
range: ByteRange {
start: sp.range.start,
end: dirty.start,
},
style: sp.style,
});
kept.push(StyleSpan {
range: ByteRange {
start: dirty.end,
end: sp.range.end,
},
style: sp.style,
});
} else if sp.range.start < dirty.start {
// Straddles the left edge only — clip to the left.
kept.push(StyleSpan {
range: ByteRange {
start: sp.range.start,
end: dirty.start,
},
style: sp.style,
});
} else if sp.range.end > dirty.end {
// Straddles the right edge only — clip to the right.
kept.push(StyleSpan {
range: ByteRange {
start: dirty.end,
end: sp.range.end,
},
style: sp.style,
});
}
// else: fully inside the dirty range ⇒ drop.
}
self.current_spans = kept;
}
for seg in segments {
self.current_spans.extend(seg.spans);
}
self.current_spans.sort_by_key(|s| s.range.start);
}
/// `Decorations { full: true, .. }` path — exactly the
/// `replace_style_spans` shape for decorations. The wire structure
/// is intentionally symmetric (`DecorationSegment` ↔ `StyleSegment`).
fn replace_decorations(&mut self, segments: Vec<DecorationSegment>) {
self.current_decorations.clear();
for seg in segments {
self.current_decorations.extend(seg.decorations);
}
self.current_decorations.sort_by_key(|d| d.range.start);
}
/// `Decorations { full: false, .. }` path — M11.4 dirty-merge for
/// decorations. Structurally identical to [`Self::merge_style_spans`]
/// — same edge-clip/drop/split logic, same trailing append +
/// re-sort.
///
/// **Recorded session-5 finding (rule iii, deferred):** this
/// duplication of the M11.4 merge algorithm across two
/// `(range, T)`-shaped types invites a generic
/// `merge_dirty_segments<T: HasRange>` helper. The refactor is
/// minor in lines but touches a load-bearing invariant; deferring
/// until at least a third instance arrives (e.g. peer-cursor
/// decorations from `PresenceUpdate`) so the abstraction is
/// inducted from three points rather than two.
fn merge_decorations(&mut self, segments: Vec<DecorationSegment>) {
for seg in &segments {
let dirty = seg.range;
let mut kept = Vec::with_capacity(self.current_decorations.len());
for d in self.current_decorations.drain(..) {
if d.range.end <= dirty.start || d.range.start >= dirty.end {
kept.push(d);
} else if d.range.start < dirty.start && d.range.end > dirty.end {
kept.push(Decoration {
range: ByteRange {
start: d.range.start,
end: dirty.start,
},
kind: d.kind,
});
kept.push(Decoration {
range: ByteRange {
start: dirty.end,
end: d.range.end,
},
kind: d.kind,
});
} else if d.range.start < dirty.start {
kept.push(Decoration {
range: ByteRange {
start: d.range.start,
end: dirty.start,
},
kind: d.kind,
});
} else if d.range.end > dirty.end {
kept.push(Decoration {
range: ByteRange {
start: dirty.end,
end: d.range.end,
},
kind: d.kind,
});
}
}
self.current_decorations = kept;
}
for seg in segments {
self.current_decorations.extend(seg.decorations);
}
self.current_decorations.sort_by_key(|d| d.range.start);
}
/// Re-build the cosmic-text Buffer from `current_text` +
/// `current_spans` + `current_decorations` +
/// `current_adornments`. Source styling/decorations remain
/// byte-indexed into `current_text`; adornments contribute extra
/// rich-text chunks at their anchors without mutating the source
/// string. That display projection is the central session-6
/// invariant: virtual text must not shift the source-byte ranges
/// used by `StyleSpans` / `Decorations`.
///
/// Complexity is O(B × (S + D)) per reshape where B is the boundary
/// count and S+D is spans+decorations. For viewport-scoped data
/// this is bounded by visible bytes. A sweep-line refactor with
/// active-set pointers is the obvious upgrade if reshape cost
/// surfaces in profile data — recorded but not done in session 5.
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();
}
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 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 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 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();
}
fn text_bounds_right(&self) -> i32 {
if self.has_minimap() {
minimap_left(self.config.width).map_or(self.config.width.cast_signed(), |left| {
(left - TEXT_RIGHT_GAP).max(TEXT_LEFT + 1.0).round() as i32
})
} else {
self.config.width.cast_signed()
}
}
fn has_minimap(&self) -> bool {
self.current_summary
.as_ref()
.is_some_and(|summary| !summary.lines.is_empty())
&& minimap_left(self.config.width).is_some()
}
fn minimap_vertex_bytes(&self) -> Vec<u8> {
let Some(summary) = self.current_summary.as_ref() else {
return Vec::new();
};
let visible_lines = estimated_visible_lines(self.config.height);
let rects = minimap_rects(
&summary.lines,
&self.current_line_shapes,
self.config.width,
self.config.height,
0,
visible_lines,
);
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Vertex bytes for quad-pipeline background rectangles covering
/// every background-bearing decoration in `current_decorations`.
/// Walks `cosmic_text::Buffer::layout_runs()` to map each
/// decoration's `ByteRange` into per-visual-line pixel rectangles:
/// a multi-line selection produces one rect per layout run that
/// carries at least one glyph whose `[start, end)` overlaps the
/// decoration. Returns an empty `Vec` when no background-bearing
/// decoration intersects any laid-out glyph.
fn decoration_background_vertex_bytes(&self) -> Vec<u8> {
let rects = self.decoration_background_rects();
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
fn decoration_background_rects(&self) -> Vec<MinimapRect> {
let mut rects = Vec::new();
for d in &self.current_decorations {
let Some(color) = decoration_kind_to_bg_color(d.kind) else {
continue;
};
let lo = d.range.start;
let hi = d.range.end;
if hi <= lo {
continue;
}
for run in self.buffer.layout_runs() {
let mut min_x: Option<f32> = None;
let mut max_x: Option<f32> = None;
for glyph in run.glyphs {
let g_start = glyph.start as u64;
let g_end = 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,
});
}
}
}
rects
}
}
#[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<glyphon::Color>,
}
fn minimap_left(surface_width: u32) -> Option<f32> {
if surface_width < MINIMAP_MIN_SURFACE_WIDTH {
return None;
}
let x = surface_width as f32 - MINIMAP_RIGHT - MINIMAP_WIDTH;
(x > TEXT_LEFT + TEXT_RIGHT_GAP).then_some(x)
}
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<MinimapRect> {
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<MinimapRect>,
x: f32,
y: f32,
h: f32,
color: [f32; 4],
shape: MinimapLineShape,
) {
if !shape.has_content() {
return;
}
let available = (MINIMAP_WIDTH - MINIMAP_H_PAD * 2.0).max(MINIMAP_MIN_STROKE_WIDTH);
let indent = (shape.indent_cols as f32 / MINIMAP_CODE_COLS * available)
.min((available - MINIMAP_MIN_STROKE_WIDTH).max(0.0));
let width = (shape.content_cols as f32 / MINIMAP_CODE_COLS * available).clamp(
MINIMAP_MIN_STROKE_WIDTH,
(available - indent).max(MINIMAP_MIN_STROKE_WIDTH),
);
rects.push(MinimapRect {
x: x + MINIMAP_H_PAD + indent,
y,
w: width,
h,
color,
});
}
fn push_minimap_thumb(
rects: &mut Vec<MinimapRect>,
x: f32,
minimap_height: f32,
line_count: usize,
first_visible_line: usize,
visible_lines: usize,
) {
let start_line = first_visible_line.min(line_count);
let end_line = start_line
.saturating_add(visible_lines.max(1))
.min(line_count);
let mut y0 = MINIMAP_TOP + start_line as f32 * minimap_height / line_count as f32;
let mut y1 = MINIMAP_TOP + end_line as f32 * minimap_height / line_count as f32;
if y1 - y0 < MINIMAP_MIN_THUMB_HEIGHT {
let mid = (y0 + y1) * 0.5;
y0 = (mid - MINIMAP_MIN_THUMB_HEIGHT * 0.5).max(MINIMAP_TOP);
y1 = (y0 + MINIMAP_MIN_THUMB_HEIGHT).min(MINIMAP_TOP + minimap_height);
y0 = (y1 - MINIMAP_MIN_THUMB_HEIGHT).max(MINIMAP_TOP);
}
let h = (y1 - y0).max(1.0);
rects.push(MinimapRect {
x,
y: y0,
w: MINIMAP_WIDTH,
h,
color: MINIMAP_THUMB_FILL,
});
rects.push(MinimapRect {
x,
y: y0,
w: 1.0,
h,
color: MINIMAP_THUMB_BORDER,
});
rects.push(MinimapRect {
x: x + MINIMAP_WIDTH - 1.0,
y: y0,
w: 1.0,
h,
color: MINIMAP_THUMB_BORDER,
});
}
fn dominant_line_style(lines: &[CellStyle]) -> CellStyle {
if lines.is_empty() {
return CellStyle::default();
}
let mut tally: Vec<(CellStyle, usize)> = Vec::new();
for style in lines {
if let Some((_, count)) = tally.iter_mut().find(|(candidate, _)| candidate == style) {
*count += 1;
} else {
tally.push((*style, 1));
}
}
tally
.into_iter()
.max_by_key(|(_, count)| *count)
.map_or(CellStyle::default(), |(style, _)| style)
}
fn dominant_line_shape(
shapes: &[MinimapLineShape],
line_start: usize,
line_end: usize,
) -> Option<MinimapLineShape> {
let slice = shapes.get(line_start.min(shapes.len())..line_end.min(shapes.len()))?;
let mut count = 0usize;
let mut indent_sum = 0usize;
let mut content_sum = 0usize;
for shape in slice.iter().filter(|shape| shape.has_content()) {
count += 1;
indent_sum += shape.indent_cols;
content_sum += shape.content_cols;
}
(count > 0).then_some(MinimapLineShape {
indent_cols: indent_sum / count,
content_cols: content_sum.div_ceil(count),
})
}
fn minimap_line_shapes(text: &str) -> Vec<MinimapLineShape> {
text.split('\n').map(minimap_line_shape).collect()
}
fn minimap_line_shape(line: &str) -> MinimapLineShape {
let mut total_cols = 0usize;
let mut indent_cols = 0usize;
let mut in_indent = true;
for ch in line.trim_end_matches('\r').chars() {
let next_col = advance_minimap_col(total_cols, ch);
if in_indent && (ch == ' ' || ch == '\t') {
indent_cols = next_col;
} else {
in_indent = false;
}
total_cols = next_col;
}
MinimapLineShape {
indent_cols,
content_cols: total_cols.saturating_sub(indent_cols),
}
}
fn advance_minimap_col(col: usize, ch: char) -> usize {
if ch == '\t' {
((col / 4) + 1) * 4
} else {
col + 1
}
}
fn minimap_style_color(style: CellStyle) -> [f32; 4] {
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,
]
}
fn rects_to_vertex_bytes(
rects: &[MinimapRect],
surface_width: u32,
surface_height: u32,
) -> Vec<u8> {
let mut bytes = Vec::with_capacity(rects.len() * 6 * QUAD_VERTEX_STRIDE as usize);
for rect in rects {
push_rect_vertices(&mut bytes, *rect, surface_width, surface_height);
}
bytes
}
fn push_rect_vertices(bytes: &mut Vec<u8>, rect: MinimapRect, width: u32, height: u32) {
if rect.w <= 0.0 || rect.h <= 0.0 || width == 0 || height == 0 {
return;
}
let x0 = px_to_ndc_x(rect.x, width);
let x1 = px_to_ndc_x(rect.x + rect.w, width);
let y0 = px_to_ndc_y(rect.y, height);
let y1 = px_to_ndc_y(rect.y + rect.h, height);
push_quad_vertex(bytes, x0, y0, rect.color);
push_quad_vertex(bytes, x1, y0, rect.color);
push_quad_vertex(bytes, x1, y1, rect.color);
push_quad_vertex(bytes, x0, y0, rect.color);
push_quad_vertex(bytes, x1, y1, rect.color);
push_quad_vertex(bytes, x0, y1, rect.color);
}
fn push_quad_vertex(bytes: &mut Vec<u8>, x: f32, y: f32, color: [f32; 4]) {
for value in [x, y, color[0], color[1], color[2], color[3]] {
bytes.extend_from_slice(&value.to_ne_bytes());
}
}
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<RichChunk> {
let text_len = text.len() as u64;
let mut boundaries: Vec<u64> = 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<u64> {
match (&adornment.placement, &adornment.content) {
(AdornmentPlacement::AtOffset, AdornmentContent::Text { .. }) => {
Some(adornment.at.min(text_len))
}
// Session 6 consumes the inlay-hint producer surface only.
// Other placements and resource handles need layout/resource
// policy, so silently ignore them until their sessions land.
_ => None,
}
}
fn push_adornments_at(
chunks: &mut Vec<RichChunk>,
adornments: &[(usize, u64, &InlineAdornment)],
next: &mut usize,
at: u64,
) {
while let Some((_, anchor, adornment)) = adornments.get(*next).copied() {
if anchor != at {
break;
}
if let AdornmentContent::Text { text, style } = &adornment.content {
chunks.push(RichChunk {
text: text.clone(),
color: Some(adornment_text_color(style.fg)),
});
}
*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<glyphon::Color> {
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<glyphon::Color> {
match c {
CellColor::Default => None,
CellColor::Rgb(r, g, b) => Some(glyphon::Color::rgb(r, g, b)),
CellColor::Indexed(idx) => Some(indexed_to_glyphon(idx)),
}
}
/// 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<glyphon::Color> {
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 ships `Selection` only. `CurrentLine` is wired in 9.2
/// (this helper will return its color then); `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]),
// 9.2 will fill this in.
DecorationKind::CurrentLine => None,
// 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>();
f32::from_ne_bytes(
bytes[start..start + std::mem::size_of::<f32>()]
.try_into()
.expect("f32 bytes"),
)
}
fn span(start: u64, end: u64, fg: CellColor) -> StyleSpan {
StyleSpan {
range: ByteRange { start, end },
style: style_with_fg(fg),
}
}
fn adornment(at: u64, placement: AdornmentPlacement, text: &str) -> InlineAdornment {
InlineAdornment {
at,
placement,
content: AdornmentContent::Text {
text: text.to_owned(),
style: Style::default(),
},
}
}
fn resource_adornment(at: u64, placement: AdornmentPlacement) -> InlineAdornment {
InlineAdornment {
at,
placement,
content: AdornmentContent::Resource { handle: 7 },
}
}
fn chunk_texts(chunks: &[RichChunk]) -> Vec<&str> {
chunks.iter().map(|chunk| chunk.text.as_str()).collect()
}
#[test]
fn bg_color_helper_covers_selection_and_returns_none_for_unrendered_kinds() {
// Session 9.1 ships `Selection` only.
assert!(decoration_kind_to_bg_color(DecorationKind::Selection).is_some());
// CurrentLine is wired in session 9.2.
assert!(decoration_kind_to_bg_color(DecorationKind::CurrentLine).is_none());
// Search-feature arc.
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 kinds that 9.1
// deliberately defers (CurrentLine, the search pair); for
// each 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::CurrentLine
| 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);
}
}