778 lines
30 KiB
Rust
778 lines
30 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, 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>,
|
||
}
|
||
|
||
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(),
|
||
}
|
||
}
|
||
|
||
/// 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 handles four variants:
|
||
/// - `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.
|
||
/// - `Goodbye` — surfaced via the reader thread's clean-EOF path,
|
||
/// not handled here.
|
||
///
|
||
/// Other `SemanticFrame` variants (`Decorations`, `InlineAdornments`,
|
||
/// `FileStyleSummary`) plus the grid variants (`CellDelta`,
|
||
/// `Cursor`, `CursorByte`) and presence updates are ignored in
|
||
/// session 4 — 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; the next
|
||
// StyleSpans frame for this buffer is authoritative.
|
||
self.current_spans.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;
|
||
}
|
||
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
|
||
}
|
||
_ => 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);
|
||
}
|
||
|
||
/// Re-build the cosmic-text Buffer from `current_text` +
|
||
/// `current_spans`. Walks the text byte-by-byte, emitting
|
||
/// `(substr, Attrs)` chunks at every span boundary — text not
|
||
/// covered by any span uses the default Attrs (terminal default
|
||
/// color). Final call: `set_rich_text` + `shape_until_scroll`.
|
||
fn reshape(&mut self) {
|
||
let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
|
||
let text_len = self.current_text.len() as u64;
|
||
let mut chunks: Vec<(String, Attrs<'static>)> = Vec::new();
|
||
let mut pos: u64 = 0;
|
||
for sp in &self.current_spans {
|
||
// Unstyled gap before this span (could be empty).
|
||
if pos < sp.range.start {
|
||
let end = sp.range.start.min(text_len) as usize;
|
||
chunks.push((
|
||
self.current_text[pos as usize..end].to_owned(),
|
||
default_attrs.clone(),
|
||
));
|
||
pos = sp.range.start;
|
||
}
|
||
// Styled run, clipped to text_len so a stale span past EOF
|
||
// can't index out.
|
||
let end = sp.range.end.min(text_len) as usize;
|
||
if end > pos as usize {
|
||
let mut attrs = default_attrs.clone();
|
||
if let Some(color) = cell_color_to_glyphon(sp.style.fg) {
|
||
attrs = attrs.color(color);
|
||
}
|
||
chunks.push((self.current_text[pos as usize..end].to_owned(), attrs));
|
||
pos = sp.range.end;
|
||
}
|
||
}
|
||
// Trailing unstyled tail.
|
||
if pos < text_len {
|
||
chunks.push((
|
||
self.current_text[pos as usize..].to_owned(),
|
||
default_attrs.clone(),
|
||
));
|
||
}
|
||
// No spans + 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)
|
||
}
|