pmacs/pmacs-gpu/src/main.rs

778 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

//! pmacs-gpu — GPU/GUI frontend for pmacs.
//!
//! Two run modes:
//!
//! - **Hello-world** (no `--attach` argument; session 2 default).
//! Opens a window and renders "hello, pmacs" in the bundled
//! `JetBrains` Mono. Used to confirm the wgpu/winit/glyphon stack
//! without depending on a daemon.
//! - **Attach** (`--attach <unix-socket-path>`; session 3+). Connects
//! to a running pmacs daemon, negotiates `semantic_render +
//! crdt_replica`, imports the daemon's `BufferSnapshot` into a
//! local loro replica, sends a `Viewport` back to request scoped
//! styling, and consumes the `StyleSpans` stream — rendering the
//! rope with per-span colors via cosmic-text's `set_rich_text`.
//! Live `CrdtOp` updates apply to the doc; subsequent `StyleSpans`
//! frames re-style.
//!
//! See `docs/pmacs-gpu-design.md` for the arc framing. Phase A's
//! adversarial-verification framing applies from session 4 forward;
//! findings classified per rule (iii) at surface-time.
//!
//! The bundled font is `JetBrains` Mono Regular, distributed under
//! the SIL Open Font License 1.1 (see `fonts/OFL.txt`).
mod attach;
use std::path::PathBuf;
use std::sync::Arc;
use glyphon::{
Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache,
TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
};
use pmacs_protocol::{
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)
}