972 lines
39 KiB
Rust
972 lines
39 KiB
Rust
//! 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::{
|
||
BufferId, ByteRange, Decoration, DecorationKind, DecorationSegment, InstanceMessage,
|
||
StyleSegment, StyleSpan, cell::Color as CellColor,
|
||
};
|
||
use wgpu::MultisampleState;
|
||
use winit::application::ApplicationHandler;
|
||
use winit::event::{ElementState, KeyEvent, WindowEvent};
|
||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||
use winit::keyboard::{Key, NamedKey};
|
||
use winit::window::{Window, WindowId};
|
||
|
||
use crate::attach::{AttachClient, AttachEvent};
|
||
|
||
/// Bundled font (SIL Open Font License 1.1 — see `fonts/OFL.txt`).
|
||
const JETBRAINS_MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono-Regular.ttf");
|
||
|
||
/// Initial window size in logical pixels.
|
||
const INITIAL_WIDTH: u32 = 800;
|
||
const INITIAL_HEIGHT: u32 = 200;
|
||
|
||
/// Color the surface clears to before text renders.
|
||
const BG: wgpu::Color = wgpu::Color {
|
||
r: 0.05,
|
||
g: 0.05,
|
||
b: 0.07,
|
||
a: 1.0,
|
||
};
|
||
|
||
/// Text the hello-world (and attach-pre-snapshot / attach-failed)
|
||
/// modes render. Once the daemon's `BufferSnapshot` arrives the
|
||
/// rendered text becomes the rope contents instead.
|
||
const HELLO_TEXT: &str = "hello, pmacs";
|
||
|
||
/// Container id the daemon uses on its loro `LoroDoc` for the
|
||
/// buffer's text. Must match `pmacs::crdt::CrdtState`'s container
|
||
/// name (`"body"`).
|
||
const LORO_TEXT_CONTAINER: &str = "body";
|
||
|
||
/// Custom events delivered to the winit event loop. The reader thread
|
||
/// in `attach.rs` forwards each decoded `InstanceMessage` through the
|
||
/// `EventLoopProxy<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,
|
||
buffer: Buffer,
|
||
/// What the buffer is currently shaped to. Held so we can detect
|
||
/// no-op updates and skip the re-shape.
|
||
current_text: String,
|
||
/// Local CRDT replica seeded by `BufferSnapshot`. `None` in
|
||
/// hello-world mode or before the first snapshot arrives in
|
||
/// attach mode.
|
||
loro_doc: Option<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>,
|
||
}
|
||
|
||
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 State {
|
||
fn new(event_loop: &ActiveEventLoop, initial_text: &str) -> Self {
|
||
let window = Arc::new(
|
||
event_loop
|
||
.create_window(
|
||
Window::default_attributes()
|
||
.with_title("pmacs-gpu")
|
||
.with_inner_size(winit::dpi::LogicalSize::new(
|
||
f64::from(INITIAL_WIDTH),
|
||
f64::from(INITIAL_HEIGHT),
|
||
)),
|
||
)
|
||
.expect("create window"),
|
||
);
|
||
|
||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||
let surface = instance
|
||
.create_surface(window.clone())
|
||
.expect("create surface");
|
||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||
power_preference: wgpu::PowerPreference::LowPower,
|
||
compatible_surface: Some(&surface),
|
||
force_fallback_adapter: false,
|
||
}))
|
||
.expect("request_adapter");
|
||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||
label: Some("pmacs-gpu device"),
|
||
required_features: wgpu::Features::empty(),
|
||
required_limits: wgpu::Limits::default(),
|
||
..wgpu::DeviceDescriptor::default()
|
||
}))
|
||
.expect("request_device");
|
||
|
||
let inner_size = window.inner_size();
|
||
let surface_caps = surface.get_capabilities(&adapter);
|
||
let surface_format = surface_caps
|
||
.formats
|
||
.iter()
|
||
.copied()
|
||
.find(wgpu::TextureFormat::is_srgb)
|
||
.unwrap_or(surface_caps.formats[0]);
|
||
let config = wgpu::SurfaceConfiguration {
|
||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||
format: surface_format,
|
||
width: inner_size.width.max(1),
|
||
height: inner_size.height.max(1),
|
||
present_mode: wgpu::PresentMode::Fifo,
|
||
desired_maximum_frame_latency: 2,
|
||
alpha_mode: surface_caps.alpha_modes[0],
|
||
view_formats: vec![],
|
||
};
|
||
surface.configure(&device, &config);
|
||
|
||
let mut font_system = FontSystem::new();
|
||
font_system.db_mut().load_font_data(JETBRAINS_MONO.to_vec());
|
||
let swash_cache = SwashCache::new();
|
||
let cache = Cache::new(&device);
|
||
let mut viewport = Viewport::new(&device, &cache);
|
||
viewport.update(
|
||
&queue,
|
||
Resolution {
|
||
width: config.width,
|
||
height: config.height,
|
||
},
|
||
);
|
||
let mut atlas = TextAtlas::new(&device, &queue, &cache, surface_format);
|
||
let text_renderer =
|
||
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
|
||
|
||
// Smaller font in attach mode (file contents tend to be more
|
||
// than one line); larger only fits "hello, pmacs"-shaped
|
||
// strings. Picked metrics that look reasonable for code at
|
||
// 800px wide.
|
||
let mut buffer = Buffer::new(&mut font_system, Metrics::new(16.0, 22.0));
|
||
buffer.set_size(
|
||
&mut font_system,
|
||
Some(config.width as f32),
|
||
Some(config.height as f32),
|
||
);
|
||
buffer.set_text(
|
||
&mut font_system,
|
||
initial_text,
|
||
&Attrs::new().family(Family::Name("JetBrains Mono")),
|
||
Shaping::Advanced,
|
||
None,
|
||
);
|
||
buffer.shape_until_scroll(&mut font_system, false);
|
||
|
||
Self {
|
||
window,
|
||
device,
|
||
queue,
|
||
surface,
|
||
config,
|
||
font_system,
|
||
swash_cache,
|
||
viewport,
|
||
atlas,
|
||
text_renderer,
|
||
buffer,
|
||
current_text: initial_text.to_owned(),
|
||
loro_doc: None,
|
||
current_buffer_id: None,
|
||
current_spans: Vec::new(),
|
||
current_decorations: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Replace the rendered text with `text` and request a redraw.
|
||
/// No-op when `text` is byte-identical to the current rendering
|
||
/// (avoids the re-shape cost when an unchanged buffer ticks).
|
||
///
|
||
/// Replaces the rope text and routes through `reshape` so the
|
||
/// rich-text rendering uses the current `current_spans`. When
|
||
/// called from the `CrdtOp` path (text shifted under existing
|
||
/// spans) the spans are momentarily stale relative to the new
|
||
/// byte positions — `reshape` clamps via `range.end.min(text_len)`
|
||
/// so rendering is safe, but visual styling may be off until the
|
||
/// daemon's next `StyleSpans` frame catches up. A real artifact;
|
||
/// classified as a session-4 known limitation rather than a bug.
|
||
fn set_text(&mut self, text: &str) {
|
||
if self.current_text == text {
|
||
return;
|
||
}
|
||
self.current_text.clear();
|
||
self.current_text.push_str(text);
|
||
self.reshape();
|
||
}
|
||
|
||
/// Apply one `InstanceMessage`; return a follow-up
|
||
/// `ViewportSend` if the message requires the main loop to fire
|
||
/// one back at the daemon.
|
||
///
|
||
/// Session 4 introduced four variants; session 5 adds
|
||
/// `Decorations`:
|
||
/// - `BufferSnapshot` — bootstrap a fresh `LoroDoc`, extract text,
|
||
/// request the daemon scope styling to the new buffer (return a
|
||
/// Viewport send-back).
|
||
/// - `CrdtOp` — apply incremental updates to the doc; text
|
||
/// re-extracted.
|
||
/// - `StyleSpans` — replace or merge per the M11.4 dirty-segment
|
||
/// rule; reshape the rich-text rendering.
|
||
/// - `Decorations` — same M11.4 shape as `StyleSpans` but for the
|
||
/// `DecorationKind` set (diagnostics, selection, current line,
|
||
/// search match). Session 5 renders diagnostic kinds as fg color
|
||
/// overrides; background-kind decorations are accumulated but
|
||
/// not painted (see session 5's deferred quad-pipeline finding).
|
||
/// - `Goodbye` — surfaced via the reader thread's clean-EOF path,
|
||
/// not handled here.
|
||
///
|
||
/// Remaining `SemanticFrame` variants (`InlineAdornments`,
|
||
/// `FileStyleSummary`) plus the grid variants (`CellDelta`,
|
||
/// `Cursor`, `CursorByte`) and presence updates are ignored in
|
||
/// session 5 — they land in subsequent Phase A sessions.
|
||
fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option<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.set_text(&text);
|
||
Some(ViewportSend {
|
||
buffer_id,
|
||
visible: ByteRange {
|
||
start: 0,
|
||
end: text_len,
|
||
},
|
||
generation: 0,
|
||
})
|
||
}
|
||
InstanceMessage::CrdtOp { buffer_id, op } => {
|
||
if self.current_buffer_id != Some(buffer_id) {
|
||
// Edit op for a different buffer than we currently
|
||
// render. Ignore for now (multi-buffer is a future
|
||
// session); when buffer-switching lands we'll
|
||
// index ops by buffer.
|
||
return None;
|
||
}
|
||
let Some(doc) = self.loro_doc.as_ref() else {
|
||
// Mid-attach race: ops before snapshot. The
|
||
// snapshot will have the ops baked in.
|
||
return None;
|
||
};
|
||
if let Err(e) = doc.import(&op.bytes) {
|
||
eprintln!("pmacs-gpu: CrdtOp import failed: {e:?}");
|
||
return None;
|
||
}
|
||
// NOTE: `current_spans` / `current_decorations` index
|
||
// into the *pre-edit* byte positions. The producer's
|
||
// next render frame (in pmacs core, post-T M11.7
|
||
// generation-transition fix) ships `full=true`
|
||
// styling for buffers whose generation advanced, so
|
||
// the next message replaces the stale items
|
||
// wholesale via `replace_style_spans` /
|
||
// `replace_decorations`. The single-frame gap
|
||
// between CrdtOp arrival and that next frame paints
|
||
// styling at stale byte positions — the session-4
|
||
// documented "one-frame stale" artifact. A previous
|
||
// attempt to fix it by clearing both vectors here
|
||
// (`49785c4`) was reverted because the producer's
|
||
// *incremental* updates ship dirty-range spans only,
|
||
// and an emptied cache loses the non-dirty viewport
|
||
// styling entirely.
|
||
let text = doc.get_text(LORO_TEXT_CONTAINER).to_string();
|
||
self.set_text(&text);
|
||
None
|
||
}
|
||
InstanceMessage::StyleSpans {
|
||
buffer_id,
|
||
generation: _,
|
||
full,
|
||
segments,
|
||
} => {
|
||
if self.current_buffer_id != Some(buffer_id) {
|
||
return None;
|
||
}
|
||
if full {
|
||
self.replace_style_spans(segments);
|
||
} else {
|
||
self.merge_style_spans(segments);
|
||
}
|
||
self.reshape();
|
||
None
|
||
}
|
||
InstanceMessage::Decorations {
|
||
buffer_id,
|
||
generation: _,
|
||
full,
|
||
segments,
|
||
} => {
|
||
if self.current_buffer_id != Some(buffer_id) {
|
||
return None;
|
||
}
|
||
if full {
|
||
self.replace_decorations(segments);
|
||
} else {
|
||
self.merge_decorations(segments);
|
||
}
|
||
self.reshape();
|
||
None
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// `full = true` path: discard prior styling, take the segments'
|
||
/// spans as authoritative for the declared viewport.
|
||
fn replace_style_spans(&mut self, segments: Vec<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`. Computes a sorted
|
||
/// boundary list (every span and decoration edge, plus 0 and
|
||
/// `text_len`) and emits one chunk per `[boundary_i, boundary_{i+1})`
|
||
/// interval. Effective color picks the first matching decoration
|
||
/// kind with a renderable color (diagnostics in session 5; the
|
||
/// background-needing kinds — `Selection` / `SearchMatch` /
|
||
/// `SearchMatchActive` / `CurrentLine` — produce `None` and fall
|
||
/// through to span color). The decoration override means a
|
||
/// diagnostic squiggle's color beats syntax color for the bytes
|
||
/// it covers, which matches user expectation across both
|
||
/// reference editors and the pmacs TUI.
|
||
///
|
||
/// Complexity is O(B × (S + D)) per reshape where B is the boundary
|
||
/// count and S+D is spans+decorations. For viewport-scoped data
|
||
/// this is bounded by visible bytes. A sweep-line refactor with
|
||
/// active-set pointers is the obvious upgrade if reshape cost
|
||
/// surfaces in profile data — recorded but not done in session 5.
|
||
fn reshape(&mut self) {
|
||
let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
|
||
let text_len = self.current_text.len() as u64;
|
||
|
||
// Collect every interesting byte position. Clamp to text_len
|
||
// so a stale span/decoration past EOF (CrdtOp→next-frame race)
|
||
// can't index out.
|
||
let mut boundaries: Vec<u64> = vec![0, text_len];
|
||
for sp in &self.current_spans {
|
||
boundaries.push(sp.range.start.min(text_len));
|
||
boundaries.push(sp.range.end.min(text_len));
|
||
}
|
||
for d in &self.current_decorations {
|
||
boundaries.push(d.range.start.min(text_len));
|
||
boundaries.push(d.range.end.min(text_len));
|
||
}
|
||
boundaries.sort_unstable();
|
||
boundaries.dedup();
|
||
|
||
let mut chunks: Vec<(String, Attrs<'static>)> = Vec::new();
|
||
for w in boundaries.windows(2) {
|
||
let (a, b) = (w[0], w[1]);
|
||
if a >= b {
|
||
continue;
|
||
}
|
||
// Pick the effective color at byte `a` (which is also the
|
||
// color for every byte in `[a, b)` since boundaries
|
||
// bracket every coverage change).
|
||
let mut color: Option<glyphon::Color> = None;
|
||
for d in &self.current_decorations {
|
||
if d.range.start <= a
|
||
&& a < d.range.end
|
||
&& let Some(c) = decoration_kind_to_color(d.kind)
|
||
{
|
||
color = Some(c);
|
||
break;
|
||
}
|
||
}
|
||
if color.is_none() {
|
||
for sp in &self.current_spans {
|
||
if sp.range.start <= a && a < sp.range.end {
|
||
color = cell_color_to_glyphon(sp.style.fg);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
let mut attrs = default_attrs.clone();
|
||
if let Some(c) = color {
|
||
attrs = attrs.color(c);
|
||
}
|
||
chunks.push((self.current_text[a as usize..b as usize].to_owned(), attrs));
|
||
}
|
||
// No spans / decorations + empty text ⇒ feed one empty chunk
|
||
// so set_rich_text has something to draw.
|
||
if chunks.is_empty() {
|
||
chunks.push((String::new(), default_attrs.clone()));
|
||
}
|
||
self.buffer.set_rich_text(
|
||
&mut self.font_system,
|
||
chunks.iter().map(|(s, a)| (s.as_str(), a.clone())),
|
||
&default_attrs,
|
||
Shaping::Advanced,
|
||
None,
|
||
);
|
||
self.buffer.shape_until_scroll(&mut self.font_system, false);
|
||
self.window.request_redraw();
|
||
}
|
||
|
||
fn resize(&mut self, width: u32, height: u32) {
|
||
self.config.width = width;
|
||
self.config.height = height;
|
||
self.surface.configure(&self.device, &self.config);
|
||
self.viewport
|
||
.update(&self.queue, Resolution { width, height });
|
||
self.buffer.set_size(
|
||
&mut self.font_system,
|
||
Some(width as f32),
|
||
Some(height as f32),
|
||
);
|
||
self.window.request_redraw();
|
||
}
|
||
|
||
fn render(&mut self) {
|
||
let frame = match self.surface.get_current_texture() {
|
||
wgpu::CurrentSurfaceTexture::Success(frame)
|
||
| wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
|
||
wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => {
|
||
self.surface.configure(&self.device, &self.config);
|
||
return;
|
||
}
|
||
wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => return,
|
||
wgpu::CurrentSurfaceTexture::Validation => {
|
||
eprintln!("surface acquisition raised a validation error");
|
||
return;
|
||
}
|
||
};
|
||
let view = frame
|
||
.texture
|
||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||
|
||
self.text_renderer
|
||
.prepare(
|
||
&self.device,
|
||
&self.queue,
|
||
&mut self.font_system,
|
||
&mut self.atlas,
|
||
&self.viewport,
|
||
[TextArea {
|
||
buffer: &self.buffer,
|
||
left: 16.0,
|
||
top: 16.0,
|
||
scale: 1.0,
|
||
bounds: TextBounds {
|
||
left: 0,
|
||
top: 0,
|
||
right: self.config.width.cast_signed(),
|
||
bottom: self.config.height.cast_signed(),
|
||
},
|
||
default_color: Color::rgb(230, 230, 235),
|
||
custom_glyphs: &[],
|
||
}],
|
||
&mut self.swash_cache,
|
||
)
|
||
.expect("text_renderer prepare");
|
||
|
||
let mut encoder = self
|
||
.device
|
||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||
label: Some("pmacs-gpu frame encoder"),
|
||
});
|
||
{
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("pmacs-gpu pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: &view,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
load: wgpu::LoadOp::Clear(BG),
|
||
store: wgpu::StoreOp::Store,
|
||
},
|
||
})],
|
||
depth_stencil_attachment: None,
|
||
occlusion_query_set: None,
|
||
timestamp_writes: None,
|
||
multiview_mask: None,
|
||
});
|
||
self.text_renderer
|
||
.render(&self.atlas, &self.viewport, &mut pass)
|
||
.expect("text_renderer render");
|
||
}
|
||
self.queue.submit(std::iter::once(encoder.finish()));
|
||
frame.present();
|
||
self.atlas.trim();
|
||
}
|
||
}
|
||
|
||
/// Convert a `pmacs-protocol::cell::Color` to a `glyphon::Color`.
|
||
/// Returns `None` for `Default` so the renderer falls back to the
|
||
/// `Attrs` default color (white-ish in our render) rather than
|
||
/// stomping with an arbitrary RGB.
|
||
///
|
||
/// `Indexed` uses the standard ANSI 16-color + 256-color cube
|
||
/// palette. The TUI interprets these via terminal-level color codes;
|
||
/// the GPU has no equivalent layer, so the palette mapping lives
|
||
/// here. Picked to roughly match `xterm-256color` defaults so
|
||
/// existing pmacs themes look consistent across both frontends.
|
||
fn cell_color_to_glyphon(c: CellColor) -> Option<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`) accumulate in
|
||
/// `current_decorations` but produce `None` here — they're recorded
|
||
/// for the future quad-pipeline session. This is the rule-(iii)
|
||
/// structural finding documented at session-5 framing: rendering
|
||
/// backgrounds needs a wgpu quad pipeline + composition story, which
|
||
/// is its own session, not absorbed into Phase A.
|
||
///
|
||
/// Color choices match the conventional editor palette (red errors,
|
||
/// yellow warnings, light blue info, dim hints) so the GPU window's
|
||
/// visual matches what the pmacs TUI paints via terminal color codes.
|
||
fn decoration_kind_to_color(kind: DecorationKind) -> Option<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 — deferred until quad pipeline.
|
||
DecorationKind::Selection
|
||
| DecorationKind::SearchMatch
|
||
| DecorationKind::SearchMatchActive
|
||
| DecorationKind::CurrentLine => None,
|
||
}
|
||
}
|