pmacs/pmacs-gpu/src/main.rs

2251 lines
84 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

//! pmacs-gpu — GPU/GUI frontend for pmacs.
//!
//! Two run modes:
//!
//! - **Hello-world** (no `--attach` argument; session 2 default).
//! Opens a window and renders "hello, pmacs" in the bundled
//! `JetBrains` Mono. Used to confirm the wgpu/winit/glyphon stack
//! without depending on a daemon.
//! - **Attach** (`--attach <unix-socket-path>`; session 3+). Connects
//! to a running pmacs daemon, negotiates `semantic_render +
//! crdt_replica`, imports the daemon's `BufferSnapshot` into a
//! local loro replica, sends a `Viewport` back to request scoped
//! styling, and consumes the `StyleSpans` stream — rendering the
//! rope with per-span colors via cosmic-text's `set_rich_text`.
//! Live `CrdtOp` updates apply to the doc; subsequent `StyleSpans`
//! frames re-style.
//!
//! See `docs/pmacs-gpu-design.md` for the arc framing. Phase A's
//! adversarial-verification framing applies from session 4 forward;
//! findings classified per rule (iii) at surface-time.
//!
//! The bundled font is `JetBrains` Mono Regular, distributed under
//! the SIL Open Font License 1.1 (see `fonts/OFL.txt`).
mod attach;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
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<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>,
/// Peer presence (session 9.3), keyed by source frontend id. Each
/// entry is one *other* attached frontend's cursor + selection,
/// delivered via `InstanceMessage::PresenceUpdate`. A read-only
/// mirror has no cursor of its own (no input path), so its own
/// `Selection` / `CurrentLine` decorations are inert; the editing
/// peer's presence is what the user actually watches. The quad-
/// background path renders `Selection` / `CurrentLine` washes from
/// these entries rather than from `current_decorations`. Sender
/// exclusion at the daemon means our own id never appears here.
peer_presences: HashMap<FrontendId, PeerPresence>,
}
/// One peer frontend's cursor + selection in a buffer, from
/// `InstanceMessage::PresenceUpdate`. Byte offsets are in the buffer's
/// coordinate space; the renderer maps them to glyph rectangles via
/// the local layout and clamps to text length, so a presence that
/// briefly lags an edit can never index out.
#[derive(Clone, Copy, Debug)]
struct PeerPresence {
buffer_id: BufferId,
cursor: u64,
selection: Option<SelectionSnapshot>,
}
struct QuadRenderer {
pipeline: wgpu::RenderPipeline,
}
#[derive(Clone, Debug)]
struct FileStyleSummaryState {
generation: u64,
lines: Vec<CellStyle>,
}
impl 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 {
#[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<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;
// 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<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();
}
#[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<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. 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<u8> {
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<MinimapRect> {
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<MinimapRect>,
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<f32> = None;
let mut max_x: Option<f32> = None;
for glyph in run.glyphs {
let g_start = line_base + glyph.start as u64;
let g_end = line_base + glyph.end as u64;
if g_end <= lo || g_start >= hi {
continue;
}
let x0 = glyph.x;
let x1 = glyph.x + glyph.w;
min_x = Some(min_x.map_or(x0, |v| v.min(x0)));
max_x = Some(max_x.map_or(x1, |v| v.max(x1)));
}
if let (Some(x0), Some(x1)) = (min_x, max_x)
&& x1 > x0
{
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<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,
]
}
/// One-shot env flag: `PMACS_GPU_DEBUG_PRESENCE=1` logs each received
/// `PresenceUpdate`. Read once (the env lock is not free per call) and
/// cached for the process lifetime.
fn debug_presence() -> bool {
static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_PRESENCE").is_some())
}
/// One-shot env flag: `PMACS_GPU_DEBUG_FRAME=1` logs per-`render()`
/// sub-phase timings (background rects, minimap rects, glyph prepare,
/// total) so a perceived cursor-tracking slowdown can be localized to
/// a specific phase.
fn debug_frame() -> bool {
static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*FLAG.get_or_init(|| std::env::var_os("PMACS_GPU_DEBUG_FRAME").is_some())
}
/// Buffer-absolute byte offset of the start of each `\n`-delimited
/// line (index 0 = byte 0). Indexed by cosmic-text's
/// `LayoutRun::line_i` to rebase line-relative glyph offsets.
fn line_byte_offsets(text: &str) -> Vec<u64> {
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<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 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>();
f32::from_ne_bytes(
bytes[start..start + std::mem::size_of::<f32>()]
.try_into()
.expect("f32 bytes"),
)
}
fn span(start: u64, end: u64, fg: CellColor) -> StyleSpan {
StyleSpan {
range: ByteRange { start, end },
style: style_with_fg(fg),
}
}
fn adornment(at: u64, placement: AdornmentPlacement, text: &str) -> InlineAdornment {
InlineAdornment {
at,
placement,
content: AdornmentContent::Text {
text: text.to_owned(),
style: Style::default(),
},
}
}
fn resource_adornment(at: u64, placement: AdornmentPlacement) -> InlineAdornment {
InlineAdornment {
at,
placement,
content: AdornmentContent::Resource { handle: 7 },
}
}
fn chunk_texts(chunks: &[RichChunk]) -> Vec<&str> {
chunks.iter().map(|chunk| chunk.text.as_str()).collect()
}
#[test]
fn source_line_range_locates_enclosing_line() {
// "abc\nde\nfgh": newlines at byte 3 and 6; len = 10.
let text = "abc\nde\nfgh";
// Cursor on line 0 → [0, 4) (includes the trailing \n).
assert_eq!(source_line_range(text, 0), (0, 4));
assert_eq!(source_line_range(text, 2), (0, 4));
// Start of line 1 → [4, 7).
assert_eq!(source_line_range(text, 4), (4, 7));
assert_eq!(source_line_range(text, 5), (4, 7));
// Last line has no trailing \n → [7, 10).
assert_eq!(source_line_range(text, 8), (7, 10));
// Cursor past end clamps to the last line, never indexes out.
assert_eq!(source_line_range(text, 99), (7, 10));
}
#[test]
fn 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);
}
}