escriba_render/gpu.rs
1//! GPU renderer — implements [`madori::RenderCallback`] backed by garasu's
2//! glyphon-wrapped text renderer. Each frame:
3//!
4//! 1. Locks the shared `EditorState`.
5//! 2. Collects visible buffer lines into a single string.
6//! 3. Builds a glyphon `Buffer` (re-created each frame — phase 1.B; phase 2
7//! will diff + reuse).
8//! 4. Prepares + renders through `madori::RenderContext::text`.
9//!
10//! Colors are the **Vellum** fleet theme (warm aged-paper Nord-matte),
11//! sourced from `escriba_ui::chrome::ChromePalette` so the GPU chrome matches
12//! the rest of the fleet (mado, tear, frostmourne, …) and escriba's TUI
13//! backend. Text is rendered in `snow1` (#E2DBC8, warm cream foreground)
14//! over a `night0` (#16140E, parchment ground) background. The status
15//! line is rendered in `ice_cyan` (#94BBB8, the matte accent).
16
17use std::sync::{Arc, Mutex};
18
19use escriba_core::{EditGen, Mode};
20use escriba_runtime::EditorState;
21use escriba_ui::chrome::ChromePalette;
22use glyphon::{Attrs, Buffer, Color as GlyphColor, Family, Metrics, Shaping, TextArea, TextBounds};
23use ishou_tokens::{EscribaSignals, Rgb, SignalMode, Srgb};
24use madori::{RenderCallback, RenderContext};
25// hikari (光) — the fleet syntax-highlighting spine. path→Box<dyn Highlighter>,
26// coverage-complete HlClass span partition, HlClass→Rgb via NordTheme.
27use hikari_core::{Ecosystem, Language, NordTheme, Rgb as HlRgb, Theme};
28
29/// Shared handle to the editor state — both the GPU renderer (reads) and
30/// the madori `on_event` callback (writes) hold one.
31pub type SharedState = Arc<Mutex<EditorState>>;
32
33/// The GPU render callback.
34///
35/// Holds a shared reference to the editor state. `render()` reads it under
36/// lock, computes a frame, releases the lock before touching the GPU to
37/// minimise contention with the event loop.
38pub struct GpuRenderer {
39 state: SharedState,
40 font_size: f32,
41 line_height: f32,
42 /// Cached font metrics — rebuilt if font_size changes.
43 metrics: Metrics,
44 /// hikari highlight registry (built once — resolves path→Highlighter).
45 eco: Ecosystem,
46 /// Nord syntax theme (HlClass→Rgb).
47 theme: NordTheme,
48 /// The refresh generation of the currently-cached text buffer — the seal
49 /// (`theory/ESCRIBA.md` §Refresh-Seal). When `EditorState::edit_gen()`
50 /// still equals this, the cached shaped buffer is reused verbatim: no
51 /// re-highlight, no re-shape. Init `u64::MAX` so the first frame always
52 /// paints.
53 last_gen: EditGen,
54 /// The shaped main-text glyphon buffer, cached across frames while the
55 /// generation is unchanged. `None` before the first paint.
56 cached_text: Option<Buffer>,
57 /// The incremental highlighter for the active buffer's language (M2). Held
58 /// across frames so a re-highlight re-lexes only the lines that changed
59 /// (hikari's `LineState` fixpoint, `theory/ESCRIBA.md` §X) instead of the
60 /// whole visible window. Keyed by path so a language switch rebuilds it;
61 /// `None` before the first paint.
62 highlighter: Option<(String, Box<dyn hikari_core::IncrementalHighlighter>)>,
63}
64
65impl GpuRenderer {
66 #[must_use]
67 pub fn new(state: SharedState) -> Self {
68 let font_size = 14.0;
69 let line_height = 20.0;
70 Self {
71 state,
72 font_size,
73 line_height,
74 metrics: Metrics::new(font_size, line_height),
75 eco: build_ecosystem(),
76 theme: NordTheme,
77 last_gen: EditGen(u64::MAX),
78 cached_text: None,
79 highlighter: None,
80 }
81 }
82
83 /// Point the editor at a theme — the wiring that makes
84 /// `(deftheme :preset …)` real.
85 ///
86 /// Writes THROUGH to the shared `EditorState`, which is the single
87 /// owner of the theme. A renderer-local copy would be a second answer
88 /// to "what colour is this editor", and the TUI face would not see it.
89 pub fn set_theme(&mut self, theme: ishou_tokens::FleetTheme) {
90 self.state
91 .lock()
92 .unwrap_or_else(std::sync::PoisonError::into_inner)
93 .set_theme(theme);
94 }
95
96 /// The palette currently painted with — read from the editor.
97 #[must_use]
98 pub fn chrome(&self) -> ChromePalette {
99 self.state
100 .lock()
101 .unwrap_or_else(std::sync::PoisonError::into_inner)
102 .chrome()
103 }
104
105 /// Builder form of [`Self::set_theme`].
106 #[must_use]
107 pub fn with_theme(mut self, theme: ishou_tokens::FleetTheme) -> Self {
108 self.set_theme(theme);
109 self
110 }
111
112 #[must_use]
113 pub fn with_font_size(mut self, font_size: f32, line_height: f32) -> Self {
114 self.font_size = font_size;
115 self.line_height = line_height;
116 self.metrics = Metrics::new(font_size, line_height);
117 self
118 }
119}
120
121impl RenderCallback for GpuRenderer {
122 fn render(&mut self, ctx: &mut RenderContext<'_>) {
123 // ── 1. Read state under lock. The visible text is built ONLY when a
124 // rebuild is due (the refresh-generation gate): an idle frame reads
125 // just mode/cursor for the status line and reuses the cached shaped
126 // buffer below — zero re-highlight, zero re-shape. `rebuild_input`
127 // is Some((text, path)) exactly when the generation moved.
128 let (rebuild_input, splash_chunks, mode, status_core, cur_gen, palette) = {
129 let s = self
130 .state
131 .lock()
132 .unwrap_or_else(std::sync::PoisonError::into_inner);
133 let Some(buf) = s.buffers.get(s.active) else {
134 return clear_frame(ctx);
135 };
136 let cur_gen = s.edit_gen();
137 let rebuild = cur_gen != self.last_gen || self.cached_text.is_none();
138 // The start screen replaces the buffer text entirely, so when
139 // one is up the (expensive) highlight+slice pass below is not
140 // merely wasted, it is wrong — it would paint the scratch
141 // buffer underneath. Laid out in CELLS, from the same estimate
142 // `resize` uses, so the screen centres on what is really there.
143 let splash_chunks = (rebuild)
144 .then(|| s.splash())
145 .flatten()
146 .map(|sp| {
147 let grid = cell_grid(ctx.width, ctx.height, self.font_size, self.line_height);
148 sp.screen_chunks(grid.cols, grid.rows)
149 })
150 .filter(|c| !c.is_empty());
151 let rebuild = rebuild && splash_chunks.is_none();
152 // (rendered text, path, search-match byte ranges INTO that text).
153 // The match ranges ride along with the text they index so the two
154 // cannot be computed against different frames.
155 let rebuild_input: Option<(String, String, Vec<(usize, usize)>)> = if rebuild {
156 // The open file's path drives hikari language resolution.
157 let path = buf
158 .path
159 .as_ref()
160 .map(|p| p.to_string_lossy().into_owned())
161 .unwrap_or_default();
162 let win = s.layout.active_window().cloned();
163 let top_line = win.as_ref().map_or(0, |w| w.viewport.top_line);
164 let left_column = win.as_ref().map_or(0, |w| w.viewport.left_column) as usize;
165 let visible_lines = win
166 .as_ref()
167 .map_or(40, |w| w.viewport.visible_lines.max(20));
168 let visible_columns = win
169 .as_ref()
170 .map_or(usize::MAX, |w| w.viewport.visible_columns as usize);
171 let mut out = String::new();
172 // Search matches are DOCUMENT char offsets; `out` is a
173 // RECONSTRUCTED string (each row trimmed of \r\n, char-sliced
174 // to the horizontal window, then \n-joined). There is
175 // therefore NO single base offset relating the two — the map
176 // has to be built per row, while we still know what each row
177 // corresponds to. Converting here, at the one place both
178 // coordinate systems are in scope, is what keeps byte/char
179 // confusion out of the painting code below.
180 let mut match_bytes: Vec<(usize, usize)> = Vec::new();
181 let hl = s.search.highlights();
182 for row in 0..visible_lines {
183 let ln = top_line + row;
184 if ln >= buf.line_count() {
185 break;
186 }
187 if let Some(line) = buf.line(ln) {
188 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
189 // Slice to the visible horizontal window
190 // `[left_column, left_column + visible_columns)`.
191 // Char-based so multibyte text stays aligned; long
192 // lines clip to the window, no glyphon wrap.
193 let sliced: String = trimmed
194 .chars()
195 .skip(left_column)
196 .take(visible_columns)
197 .collect();
198 if !hl.is_empty() {
199 let seg_byte0 = out.len();
200 // Document char span this rendered segment covers.
201 let doc0 = buf
202 .position_to_char(escriba_core::Position::new(ln, 0))
203 .unwrap_or(0)
204 + left_column;
205 let seg_chars = sliced.chars().count();
206 // char index -> byte index within this segment.
207 let bytes: Vec<usize> = sliced
208 .char_indices()
209 .map(|(b, _)| b)
210 .chain(std::iter::once(sliced.len()))
211 .collect();
212 for m in hl {
213 let a = m.start.max(doc0);
214 let b = m.end.min(doc0 + seg_chars);
215 if a < b {
216 match_bytes.push((
217 seg_byte0 + bytes[a - doc0],
218 seg_byte0 + bytes[b - doc0],
219 ));
220 }
221 }
222 }
223 out.push_str(&sliced);
224 out.push('\n');
225 }
226 }
227 Some((out, path, match_bytes))
228 } else {
229 None
230 };
231 (
232 rebuild_input,
233 splash_chunks,
234 s.modal.mode(),
235 s.status_model().render(),
236 cur_gen,
237 s.chrome(),
238 )
239 };
240
241 // ── 2. Rebuild the shaped main-text buffer ONLY on a generation
242 // change; otherwise reuse the cached one. This is the seal
243 // (theory/ESCRIBA.md §Refresh-Seal): highlight + set_rich_text +
244 // shape — the frame's dominant cost — run once per edit, never
245 // per vsync.
246 let fg = chrome_glyph(palette.text);
247 let width = ctx.width as f32;
248 let height = ctx.height as f32 - self.line_height; // reserve bottom row for status
249 if let Some(chunks) = splash_chunks {
250 // The start screen: same laid-out stream the ANSI face
251 // consumes, roles turned into glyphon attrs instead of SGR.
252 let base = Attrs::new().family(Family::Monospace);
253 let mut buffer = Buffer::new(&mut ctx.text.font_system, self.metrics);
254 buffer.set_size(&mut ctx.text.font_system, Some(width), Some(height));
255 let runs: Vec<(&str, Attrs)> = splash_runs(&chunks, &palette)
256 .into_iter()
257 .map(|(text, color)| (text, base.clone().color(color)))
258 .collect();
259 buffer.set_rich_text(
260 &mut ctx.text.font_system,
261 runs,
262 &base,
263 Shaping::Advanced,
264 None,
265 );
266 buffer.shape_until_scroll(&mut ctx.text.font_system, false);
267 self.cached_text = Some(buffer);
268 self.last_gen = cur_gen;
269 } else if let Some((text, path, match_bytes)) = rebuild_input {
270 let mut buffer = Buffer::new(&mut ctx.text.font_system, self.metrics);
271 buffer.set_size(&mut ctx.text.font_system, Some(width), Some(height));
272 // hikari: resolve the language, highlight the visible text, paint
273 // each span its Nord color. The span vec is a coverage-complete,
274 // non-overlapping, sorted partition of `text` (the SpanSink
275 // invariant), so each (slice, color) run is a valid set_rich_text
276 // item. Offsets are self-consistent (highlight == render string).
277 let base = Attrs::new().family(Family::Monospace);
278 // hikari incremental (M2): reuse the per-path LineCache and re-lex
279 // only the lines that changed since the last frame (the LineState
280 // fixpoint). A language switch (path change) rebuilds the cache; a
281 // scroll re-lexes the newly-visible window (graceful degrade). This
282 // is byte-identical to the one-shot highlighter it replaces.
283 if self.highlighter.as_ref().is_none_or(|(p, _)| p != &path) {
284 self.highlighter = Some((
285 path.clone(),
286 self.eco.incremental_highlighter_for_path(&path),
287 ));
288 }
289 let hl = &mut self
290 .highlighter
291 .as_mut()
292 .expect("highlighter set immediately above")
293 .1;
294 let spans = hl.highlight(&text);
295 // Overlay search matches on the syntax partition. Each syntax
296 // span is cut at any match boundary crossing it and the matched
297 // piece is recoloured; the result is still coverage-complete,
298 // non-overlapping and sorted, which is what set_rich_text
299 // requires — splitting a partition preserves that, replacing it
300 // would not.
301 let search_color = chrome_glyph(palette.warning);
302 let runs: Vec<(&str, Attrs)> = spans
303 .iter()
304 .flat_map(|sp| {
305 let syntax = base.clone().color(hl_to_glyph(self.theme.color(sp.class)));
306 split_on_matches(sp.span.range(), &match_bytes)
307 .into_iter()
308 .filter_map(|(r, is_match)| {
309 text.get(r).map(|slice| {
310 (
311 slice,
312 if is_match {
313 base.clone().color(search_color)
314 } else {
315 syntax.clone()
316 },
317 )
318 })
319 })
320 .collect::<Vec<_>>()
321 })
322 .collect();
323 buffer.set_rich_text(
324 &mut ctx.text.font_system,
325 runs,
326 &base,
327 Shaping::Advanced,
328 None,
329 );
330 buffer.shape_until_scroll(&mut ctx.text.font_system, false);
331 self.cached_text = Some(buffer);
332 self.last_gen = cur_gen;
333 }
334 let buffer = self
335 .cached_text
336 .as_ref()
337 .expect("cached_text is built on the first frame (last_gen inits to u64::MAX)");
338
339 // Status line — rendered as its own glyphon buffer. The mode is the
340 // BORN fleet mode glyph (`ishou_tokens::EscribaSignals`) + escriba's
341 // canonical uppercase mode label.
342 let signals = EscribaSignals::prescribed();
343 // Built from `EditorState::status_model()` — the ONE model the
344 // ratatui face renders too, so the two can differ only in styling.
345 // This replaces a fixed `format!()` that carried mode/line/col/version
346 // and read neither the prompt nor any message: typing `/foo` on this
347 // face moved the cursor with nothing on screen to show for it, which
348 // is why search looked absent on escriba's default renderer.
349 //
350 // `push_str`, not `format!` — ★★ TYPED EMISSION.
351 let mut status = String::with_capacity(status_core.len() + 24);
352 status.push(' ');
353 status.push_str(mode_glyph(&signals, mode).render(SignalMode::Glyph));
354 status.push(' ');
355 status.push_str(&status_core);
356 status.push_str(" escriba v");
357 status.push_str(env!("CARGO_PKG_VERSION"));
358 status.push(' ');
359 let mut status_buf = Buffer::new(&mut ctx.text.font_system, self.metrics);
360 status_buf.set_size(
361 &mut ctx.text.font_system,
362 Some(width),
363 Some(self.line_height * 2.0),
364 );
365 status_buf.set_text(
366 &mut ctx.text.font_system,
367 &status,
368 &Attrs::new().family(Family::Monospace),
369 Shaping::Advanced,
370 );
371 status_buf.shape_until_scroll(&mut ctx.text.font_system, false);
372
373 let status_color = chrome_glyph(palette.info);
374
375 let text_areas = [
376 TextArea {
377 buffer,
378 left: 8.0,
379 top: 8.0,
380 scale: 1.0,
381 bounds: TextBounds {
382 left: 0,
383 top: 0,
384 right: ctx.width as i32,
385 bottom: (height as i32).max(0),
386 },
387 default_color: fg,
388 custom_glyphs: &[],
389 },
390 TextArea {
391 buffer: &status_buf,
392 left: 8.0,
393 top: (ctx.height as f32 - self.line_height - 4.0).max(0.0),
394 scale: 1.0,
395 bounds: TextBounds {
396 left: 0,
397 top: (ctx.height as i32 - self.line_height as i32 - 4).max(0),
398 right: ctx.width as i32,
399 bottom: ctx.height as i32,
400 },
401 default_color: status_color,
402 custom_glyphs: &[],
403 },
404 ];
405
406 if let Err(e) = ctx.text.prepare(
407 &ctx.gpu.device,
408 &ctx.gpu.queue,
409 ctx.width,
410 ctx.height,
411 text_areas,
412 ) {
413 tracing::warn!(error = %e, "glyphon prepare failed");
414 return clear_frame(ctx);
415 }
416
417 // ── 3. Encode frame. ───────────────────────────────────────────
418 let mut encoder = ctx
419 .gpu
420 .device
421 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
422 label: Some("escriba frame"),
423 });
424 {
425 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
426 label: Some("escriba main pass"),
427 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
428 view: ctx.surface_view,
429 resolve_target: None,
430 ops: wgpu::Operations {
431 load: wgpu::LoadOp::Clear(ground_bg(&palette)),
432 store: wgpu::StoreOp::Store,
433 },
434 })],
435 depth_stencil_attachment: None,
436 timestamp_writes: None,
437 occlusion_query_set: None,
438 });
439 if let Err(e) = ctx.text.render(&mut pass) {
440 tracing::warn!(error = %e, "glyphon render failed");
441 }
442 }
443 ctx.gpu.queue.submit(std::iter::once(encoder.finish()));
444 }
445
446 fn resize(&mut self, width: u32, height: u32) {
447 if let Ok(mut s) = self.state.lock() {
448 // The SAME grid the start screen is laid out on — one estimate,
449 // one status-row reservation, one place to fix either.
450 let grid = cell_grid(width, height, self.font_size, self.line_height);
451 for w in &mut s.layout.windows {
452 w.rect.width = width;
453 w.rect.height = height;
454 w.viewport.visible_lines = u32::from(grid.rows);
455 w.viewport.visible_columns = u32::from(grid.cols);
456 }
457 }
458 }
459}
460
461/// The highlight registry escriba renders through: **tree-sitter grammars
462/// (hikari-ts) take precedence** for the languages they cover, and the zero-dep
463/// table backend fills every other language. So `.rs` gets real tree-sitter
464/// highlighting while `.py` / `.lisp` / `.json` / … get the batteries-included
465/// table lexer — and both flow through the same coverage-complete `HlClass`
466/// partition. Registration order is load-bearing: `Ecosystem::resolve` returns
467/// the first matching plugin, so tree-sitter (registered first) wins for its
468/// languages; the table backend is skipped for any language tree-sitter already
469/// covers (no duplicate). If the tree-sitter host fails to build, the table
470/// backend covers everything — never a panic, never an empty registry.
471///
472/// A third tier registers last: [`crate::langs::escriba_local`], the languages
473/// escriba serves that the fleet spine does not ship yet (today: blue). Last
474/// means an upstream hikari backend for the same language always wins, so a
475/// local table retires itself the day hikari grows one — no edit here, and no
476/// window where the two disagree.
477///
478/// Public because the registry IS escriba's language surface: a test that asks
479/// "does the editor know this language?" must be able to ask the same object
480/// the renderer holds, not a reconstruction of it.
481#[must_use]
482pub fn build_ecosystem() -> Ecosystem {
483 let mut eco = Ecosystem::new();
484 let mut covered: Vec<Language> = Vec::new();
485 if let Ok(host) = hikari_ts::TreeSitterHost::builtin() {
486 for p in host.plugins() {
487 covered.push(p.language());
488 eco.register(p);
489 }
490 }
491 for p in hikari_core::langs::builtins() {
492 if !covered.contains(&p.language()) {
493 covered.push(p.language());
494 eco.register(p);
495 }
496 }
497 for p in crate::langs::escriba_local() {
498 if !covered.contains(&p.language()) {
499 eco.register(p);
500 }
501 }
502 eco
503}
504
505/// Pair each start-screen chunk with the colour its ROLE resolves to under
506/// `palette` — the GPU face's half of the role→paint mapping, extracted so
507/// it can be tested without a device.
508///
509/// This is the piece of the splash path that can be wrong in a way glyphon
510/// would not notice: a mis-mapped role paints the menu keys as body text and
511/// renders perfectly. The plumbing either side (buffer sizing, shaping) is
512/// upstream's contract; this is ours.
513///
514/// Borrows from `chunks`, so the returned slices concatenate to exactly the
515/// screen — the coverage-complete partition `set_rich_text` requires.
516///
517/// Public so `tests/gpu_logic.rs` can assert on the REAL mapping rather
518/// than on a reconstruction of it; a test that rebuilt this from
519/// `screen_chunks` would pass even if the renderer stopped calling it.
520#[must_use]
521pub fn splash_runs<'a>(
522 chunks: &'a [escriba_ui::splash::SplashSpan],
523 palette: &ChromePalette,
524) -> Vec<(&'a str, GlyphColor)> {
525 chunks
526 .iter()
527 .map(|c| (c.text.as_str(), chrome_glyph(c.role.color(palette))))
528 .collect()
529}
530
531/// The character grid a pixel surface maps to.
532///
533/// Both the viewport (how many buffer lines and columns fit) and the start
534/// screen (what canvas to centre on) need this, and they used to compute it
535/// separately: `resize` divided height by line-height and subtracted a row,
536/// `render` subtracted a line-height and then divided. Same intent, two
537/// spellings, two places to get the status-row reservation wrong.
538///
539/// Pure and total — no GPU, no state — which is what makes the one piece of
540/// arithmetic in the GPU face that can actually be WRONG testable without a
541/// device. The `0.6` is glyphon's monospace advance ratio for
542/// `Family::Monospace`: an estimate, and the honest reason the start screen
543/// centres approximately rather than exactly.
544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545pub struct CellGrid {
546 pub cols: u16,
547 pub rows: u16,
548}
549
550/// Advance-to-font-size ratio for glyphon's monospace face.
551const MONO_ADVANCE_RATIO: f32 = 0.6;
552
553#[must_use]
554pub fn cell_grid(width_px: u32, height_px: u32, font_size: f32, line_height: f32) -> CellGrid {
555 let cell_w = (font_size * MONO_ADVANCE_RATIO).max(1.0);
556 let cell_h = line_height.max(1.0);
557 let cols = (width_px as f32 / cell_w).floor().max(1.0);
558 // One row is reserved for the status line, which is drawn as its own
559 // text area below the main pane. Reserved ONCE, here, so no caller can
560 // forget it or subtract it twice.
561 let rows = (height_px as f32 / cell_h).floor().max(2.0) - 1.0;
562 CellGrid {
563 cols: cols.min(f32::from(u16::MAX)) as u16,
564 rows: rows.min(f32::from(u16::MAX)) as u16,
565 }
566}
567
568/// Utility — clear the frame to the ground colour. Used on error paths.
569///
570/// This one legitimately paints the FLEET-PRESCRIBED ground rather than the
571/// operator's: it runs when the editor state could not be read (no active
572/// buffer, a failed glyphon prepare), which is exactly when the operator's
573/// theme is unknowable. A dark frame in the default theme beats a panic or
574/// an undefined surface.
575fn clear_frame(ctx: &mut RenderContext<'_>) {
576 let palette = ChromePalette::prescribed();
577 let mut encoder = ctx
578 .gpu
579 .device
580 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
581 label: Some("escriba clear"),
582 });
583 {
584 let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
585 label: Some("escriba clear pass"),
586 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
587 view: ctx.surface_view,
588 resolve_target: None,
589 ops: wgpu::Operations {
590 load: wgpu::LoadOp::Clear(ground_bg(&palette)),
591 store: wgpu::StoreOp::Store,
592 },
593 })],
594 depth_stencil_attachment: None,
595 timestamp_writes: None,
596 occlusion_query_set: None,
597 });
598 }
599 ctx.gpu.queue.submit(std::iter::once(encoder.finish()));
600}
601
602/// The editor ground as `wgpu::Color`, resolved from the fleet-prescribed
603/// theme's `background` role. Gamma-correct: the sRGB token is promoted
604/// through `ishou_tokens`' typed sRGB→linear path so it composites
605/// correctly on the linear-storage surface.
606fn ground_bg(c: &ChromePalette) -> wgpu::Color {
607 Srgb::from(c.background).to_linear().with_alpha(1.0).into()
608}
609
610/// ishou `Rgb` → glyphon `Color` (sRGB u8 RGBA, opaque). Theme-agnostic —
611/// was `vellum_glyph`, back when the paint path was hardwired to Vellum.
612/// Cut `range` wherever a match in `matches` starts or ends inside it.
613///
614/// Returns `(sub_range, is_match)` pieces that are contiguous, in order, and
615/// exactly cover `range` — the property `set_rich_text` depends on. `matches`
616/// are byte ranges into the SAME string `range` indexes.
617///
618/// Splitting the existing syntax partition (rather than building a second one)
619/// is what keeps the two colour sources composable: a match inside a string
620/// literal recolours only the matched bytes and the literal keeps its colour
621/// either side.
622fn split_on_matches(
623 range: std::ops::Range<usize>,
624 matches: &[(usize, usize)],
625) -> Vec<(std::ops::Range<usize>, bool)> {
626 let mut cuts: Vec<usize> = vec![range.start, range.end];
627 for &(a, b) in matches {
628 if a > range.start && a < range.end {
629 cuts.push(a);
630 }
631 if b > range.start && b < range.end {
632 cuts.push(b);
633 }
634 }
635 if cuts.len() == 2 {
636 // No boundary crosses this span — the common case, so avoid the
637 // sort/dedup entirely.
638 let hit = matches
639 .iter()
640 .any(|&(a, b)| a <= range.start && b >= range.end);
641 return vec![(range, hit)];
642 }
643 cuts.sort_unstable();
644 cuts.dedup();
645 cuts.windows(2)
646 .map(|w| {
647 let (a, b) = (w[0], w[1]);
648 let hit = matches.iter().any(|&(ms, me)| ms <= a && me >= b);
649 (a..b, hit)
650 })
651 .collect()
652}
653
654fn chrome_glyph(c: Rgb) -> GlyphColor {
655 GlyphColor::rgba(c.r, c.g, c.b, 0xFF)
656}
657
658/// hikari `Rgb` (sRGB u8) → glyphon `Color` (opaque) — the syntax-span paint.
659fn hl_to_glyph(c: HlRgb) -> GlyphColor {
660 GlyphColor::rgba(c.r, c.g, c.b, 0xFF)
661}
662
663/// Mode indicator color — used by higher-layer rendering paths that want a
664/// glance-readable color. Named by ROLE so the hue follows the active theme:
665/// Normal info, Insert success, Visual accent, Command warning.
666#[must_use]
667pub fn mode_color(c: &ChromePalette, mode: Mode) -> Rgb {
668 match mode {
669 Mode::Insert => c.success,
670 Mode::Command => c.warning,
671 Mode::Visual | Mode::VisualLine => c.accent,
672 Mode::Normal => c.info,
673 }
674}
675
676/// The [`CursorShape`](escriba_core::CursorShape) the GPU backend should
677/// draw for `mode`. Derived from the single typed `Mode::cursor_shape`
678/// mapping shared with the TUI backend — so the GPU cursor (once it gains a
679/// dedicated glyph; today the buffer text carries the caret) renders the
680/// same shape the TUI does for any given mode. Exposed now so the shape is
681/// a typed value at the GPU layer, not a renderer-local literal later.
682#[must_use]
683pub fn cursor_shape(mode: Mode) -> escriba_core::CursorShape {
684 mode.cursor_shape()
685}
686
687/// Map an editor [`Mode`] to its fleet [`Signal`](ishou_tokens::Signal)
688/// from [`EscribaSignals`].
689///
690/// `VisualLine` shares `mode_visual` with `Visual` — the fleet signal set
691/// has one visual signal, matching how [`mode_color`] groups the two.
692#[must_use]
693pub fn mode_glyph(sig: &EscribaSignals, mode: Mode) -> &ishou_tokens::Signal {
694 match mode {
695 Mode::Normal => &sig.mode_normal,
696 Mode::Insert => &sig.mode_insert,
697 Mode::Visual | Mode::VisualLine => &sig.mode_visual,
698 Mode::Command => &sig.mode_command,
699 }
700}
701
702#[cfg(test)]
703mod tests {
704
705 // ── search-highlight overlay ──────────────────────────────────────
706 //
707 // set_rich_text requires a coverage-complete, non-overlapping, sorted
708 // partition. Splitting the syntax partition preserves that; these pin it,
709 // because a violation shows up as garbled text rather than a panic.
710
711 /// The invariant, asserted directly: pieces are contiguous, ordered, and
712 /// exactly cover the input range.
713 fn assert_partition(range: std::ops::Range<usize>, out: &[(std::ops::Range<usize>, bool)]) {
714 assert!(!out.is_empty(), "a range must yield at least one piece");
715 assert_eq!(out[0].0.start, range.start, "starts at the range start");
716 assert_eq!(out[out.len() - 1].0.end, range.end, "ends at the range end");
717 for w in out.windows(2) {
718 assert_eq!(
719 w[0].0.end, w[1].0.start,
720 "pieces are contiguous, no gap or overlap"
721 );
722 }
723 }
724
725 #[test]
726 fn a_span_with_no_match_is_returned_whole() {
727 let out = split_on_matches(0..10, &[]);
728 assert_eq!(out.len(), 1, "no needless splitting");
729 assert!(!out[0].1);
730 assert_partition(0..10, &out);
731 }
732
733 #[test]
734 fn a_match_covering_the_whole_span_marks_it_without_splitting() {
735 let out = split_on_matches(4..8, &[(0, 20)]);
736 assert_eq!(out.len(), 1);
737 assert!(out[0].1, "fully covered span is a match");
738 assert_partition(4..8, &out);
739 }
740
741 #[test]
742 fn a_match_starting_mid_span_splits_it_in_two() {
743 // Syntax span 0..10, match 5..10 -> [0..5 plain][5..10 match]
744 let out = split_on_matches(0..10, &[(5, 10)]);
745 assert_eq!(out.len(), 2);
746 assert_eq!(out[0], (0..5, false));
747 assert_eq!(out[1], (5..10, true));
748 assert_partition(0..10, &out);
749 }
750
751 #[test]
752 fn a_match_inside_a_span_splits_it_in_three() {
753 // This is the case that matters: a match inside a string literal must
754 // recolour only the matched bytes, leaving the literal coloured
755 // either side.
756 let out = split_on_matches(0..10, &[(3, 6)]);
757 assert_eq!(out.len(), 3);
758 assert_eq!(out[0], (0..3, false));
759 assert_eq!(out[1], (3..6, true));
760 assert_eq!(out[2], (6..10, false));
761 assert_partition(0..10, &out);
762 }
763
764 #[test]
765 fn two_matches_in_one_span_both_split() {
766 let out = split_on_matches(0..20, &[(2, 4), (10, 12)]);
767 assert_partition(0..20, &out);
768 let hits: Vec<_> = out
769 .iter()
770 .filter(|(_, m)| *m)
771 .map(|(r, _)| r.clone())
772 .collect();
773 assert_eq!(hits, vec![2..4, 10..12]);
774 }
775
776 #[test]
777 fn a_match_entirely_outside_the_span_changes_nothing() {
778 let out = split_on_matches(10..20, &[(0, 5)]);
779 assert_eq!(out.len(), 1);
780 assert!(!out[0].1);
781 assert_partition(10..20, &out);
782 }
783
784 #[test]
785 fn a_match_touching_the_span_edge_does_not_create_an_empty_piece() {
786 // Boundary exactly at the edge must not emit a zero-width run.
787 for m in [(0usize, 10usize), (10, 20)] {
788 let out = split_on_matches(10..20, &[m]);
789 assert_partition(10..20, &out);
790 assert!(
791 out.iter().all(|(r, _)| r.start < r.end),
792 "no empty piece for {m:?}"
793 );
794 }
795 }
796
797 #[test]
798 fn adjacent_matches_do_not_produce_duplicate_cuts() {
799 // Two matches meeting at 5 must yield one cut there, not two.
800 let out = split_on_matches(0..10, &[(0, 5), (5, 10)]);
801 assert_partition(0..10, &out);
802 assert!(out.iter().all(|(r, _)| r.start < r.end));
803 assert!(out.iter().all(|(_, m)| *m), "both halves are matches");
804 }
805 use super::*;
806 use escriba_buffer::BufferSet;
807
808 #[test]
809 fn ground_is_the_prescribed_theme_promoted_to_linear() {
810 let bg = ground_bg(&ChromePalette::prescribed());
811 // Was pinned to Vellum's warm parchment (night0 #16140E, r >= g >= b).
812 // The prescribed theme is now Nord, whose ground is COOL (b >= r), so
813 // the old warmth assertion was theme-specific and had to go. What is
814 // actually invariant — and worth asserting — is that the ground is a
815 // dark, opaque, gamma-correct promotion of the theme's own
816 // background role.
817 let want = Srgb::from(ChromePalette::prescribed().background)
818 .to_linear()
819 .with_alpha(1.0);
820 let want: wgpu::Color = want.into();
821 assert!((bg.r - want.r).abs() < 1e-6, "r {} != {}", bg.r, want.r);
822 assert!((bg.g - want.g).abs() < 1e-6, "g {} != {}", bg.g, want.g);
823 assert!((bg.b - want.b).abs() < 1e-6, "b {} != {}", bg.b, want.b);
824 assert_eq!(bg.a, 1.0);
825 // Dark ground: an editor background must stay well below mid-grey in
826 // linear space whatever the theme.
827 assert!(
828 bg.r < 0.1 && bg.g < 0.1 && bg.b < 0.1,
829 "ground is not dark: {bg:?}"
830 );
831 }
832
833 #[test]
834 fn renderer_construction_is_cheap() {
835 let mut bufs = BufferSet::new();
836 let id = bufs.scratch("hello\n");
837 let state = Arc::new(Mutex::new(EditorState::new_with_buffer(bufs, id)));
838 let _r = GpuRenderer::new(state);
839 }
840
841 /// Phase 4: the render Ecosystem serves `.rs` from the **tree-sitter**
842 /// backend (hikari-ts) and other languages from the table backend — both a
843 /// coverage-complete `HlClass` partition. Proves real tree-sitter
844 /// highlighting is wired into the live render path (not just the table lexer).
845 #[test]
846 fn ecosystem_uses_tree_sitter_for_rust_and_table_for_the_rest() {
847 use hikari_core::{HlClass, Language};
848 let eco = build_ecosystem();
849 // .rs resolves to a grammar and produces real (non-Plain) classification.
850 assert_eq!(eco.resolve("src/main.rs"), Language("rust"));
851 let rs = eco
852 .highlighter_for_path("src/main.rs")
853 .highlight("fn main() { let x = 42; }");
854 assert!(
855 rs.iter().any(|s| s.class != HlClass::Plain),
856 "rust must be really highlighted (tree-sitter or table)",
857 );
858 // Python is also served (tree-sitter, once hikari-ts ships that grammar;
859 // the table backend covers it otherwise) — either way it classifies.
860 assert_eq!(eco.resolve("app.py"), Language("python"));
861 // A tree-sitter-uncovered language still resolves via the table backend.
862 assert_eq!(eco.resolve("init.lisp"), Language("lisp"));
863 // An unknown extension is still total (plain text, never a panic).
864 assert_eq!(eco.resolve("notes.xyz"), hikari_core::PLAIN_TEXT);
865 }
866
867 #[test]
868 fn mode_colors_differ_by_mode() {
869 let n = mode_color(&ChromePalette::prescribed(), Mode::Normal);
870 let i = mode_color(&ChromePalette::prescribed(), Mode::Insert);
871 let v = mode_color(&ChromePalette::prescribed(), Mode::Visual);
872 assert_ne!((n.r, n.g, n.b), (i.r, i.g, i.b));
873 assert_ne!((n.r, n.g, n.b), (v.r, v.g, v.b));
874 }
875
876 #[test]
877 fn cursor_shape_tracks_mode() {
878 use escriba_core::CursorShape;
879 assert_eq!(cursor_shape(Mode::Normal), CursorShape::Block);
880 assert_eq!(cursor_shape(Mode::Command), CursorShape::Block);
881 assert_eq!(cursor_shape(Mode::Insert), CursorShape::Bar);
882 assert_eq!(cursor_shape(Mode::Visual), CursorShape::Underline);
883 assert_eq!(cursor_shape(Mode::VisualLine), CursorShape::Underline);
884 }
885
886 /// Mode pills map to ROLES, not to one theme's hexes. This test used to
887 /// pin the four Vellum values (`#94BBB8` …), which is precisely why it
888 /// went red the moment the fleet theme moved — a test asserting a
889 /// theme's spelling has to be rewritten on every theme change, and is
890 /// no evidence the mapping is right. Asserting role identity instead
891 /// survives the move AND still catches a mis-wired pill.
892 #[test]
893 fn mode_colors_are_role_pills() {
894 let c = ChromePalette::prescribed();
895 assert_eq!(
896 mode_color(&ChromePalette::prescribed(), Mode::Normal).hex(),
897 c.info.hex(),
898 "Normal = info"
899 );
900 assert_eq!(
901 mode_color(&ChromePalette::prescribed(), Mode::Insert).hex(),
902 c.success.hex(),
903 "Insert = success"
904 );
905 assert_eq!(
906 mode_color(&ChromePalette::prescribed(), Mode::Visual).hex(),
907 c.accent.hex(),
908 "Visual = accent"
909 );
910 assert_eq!(
911 mode_color(&ChromePalette::prescribed(), Mode::Command).hex(),
912 c.warning.hex(),
913 "Command = warning"
914 );
915
916 // The four pills must be mutually distinct, or the mode is not
917 // glance-readable regardless of which theme is active.
918 let mut seen = std::collections::BTreeSet::new();
919 for m in [Mode::Normal, Mode::Insert, Mode::Visual, Mode::Command] {
920 assert!(
921 seen.insert(mode_color(&ChromePalette::prescribed(), m).hex()),
922 "{m:?} duplicates another pill"
923 );
924 }
925 }
926
927 /// Forcing function: the status-line mode glyphs are sourced from the
928 /// fleet `EscribaSignals` vocabulary, not hand-picked literals. Pins
929 /// the geometric `Glyph`-mode marks so drift in either escriba or
930 /// ishou surfaces here.
931 #[test]
932 fn mode_glyphs_are_fleet_signals() {
933 let sig = EscribaSignals::prescribed();
934 assert_eq!(
935 mode_glyph(&sig, Mode::Normal).render(SignalMode::Glyph),
936 "◆"
937 );
938 assert_eq!(
939 mode_glyph(&sig, Mode::Insert).render(SignalMode::Glyph),
940 "▸"
941 );
942 assert_eq!(
943 mode_glyph(&sig, Mode::Visual).render(SignalMode::Glyph),
944 "▮"
945 );
946 assert_eq!(
947 mode_glyph(&sig, Mode::VisualLine).render(SignalMode::Glyph),
948 "▮"
949 );
950 assert_eq!(
951 mode_glyph(&sig, Mode::Command).render(SignalMode::Glyph),
952 ":"
953 );
954 }
955
956 /// Fleet convergence guard: escriba's GPU chrome paints whatever
957 /// `ChromePalette::prescribed()` resolves, which is
958 /// `FleetTheme::prescribed_default()` BY CONSTRUCTION — so this Guard
959 /// cannot be satisfied by a stale hand-written constant.
960 ///
961 /// It previously hardcoded `FleetTheme::Vellum` to match a paint path
962 /// hardwired to `VellumPalette::vellum()`. When the fleet moved its
963 /// prescribed theme to PlemeDark (Nord) this went RED — correctly, since
964 /// the GPU backend really was painting the wrong theme while the TUI
965 /// face and the rest of the fleet (mado, tear, frostmourne, …) moved on.
966 /// Smallest real editor state — a scratch buffer. The theming tests
967 /// care about the palette, not the buffer, but GpuRenderer owns state.
968 fn test_renderer() -> GpuRenderer {
969 let mut bufs = escriba_buffer::BufferSet::new();
970 let id = bufs.scratch("");
971 GpuRenderer::new(Arc::new(Mutex::new(EditorState::new_with_buffer(bufs, id))))
972 }
973
974 #[test]
975 fn default_theme_is_the_fleet_prescribed_nord() {
976 // Nord is the default because the FLEET says so — asserted against
977 // FleetTheme::prescribed_default(), never a hand-written "nord",
978 // so a fleet re-point cannot leave escriba silently behind.
979 let r = test_renderer();
980 let want = ChromePalette::for_theme(ishou_tokens::FleetTheme::prescribed_default());
981 assert_eq!(r.chrome().hex_tuple(), want.hex_tuple());
982 }
983
984 #[test]
985 fn set_theme_actually_changes_what_is_painted() {
986 // The wiring this exists to prove: before it, every paint site
987 // called ChromePalette::prescribed() directly, so (deftheme :preset)
988 // resolved to a real FleetTheme that NOTHING consumed. If set_theme
989 // ever stops reaching the paint path, this fails.
990 let mut r = test_renderer();
991 let before = r.chrome().hex_tuple();
992 r.set_theme(ishou_tokens::FleetTheme::Vellum);
993 let after = r.chrome().hex_tuple();
994 assert_ne!(
995 before, after,
996 "switching to Vellum must change the painted palette"
997 );
998 assert_eq!(
999 after,
1000 ChromePalette::for_theme(ishou_tokens::FleetTheme::Vellum).hex_tuple()
1001 );
1002 // And it is reversible — a theme is a value, not a one-way latch.
1003 r.set_theme(ishou_tokens::FleetTheme::prescribed_default());
1004 assert_eq!(r.chrome().hex_tuple(), before);
1005 }
1006
1007 #[test]
1008 fn escriba_gpu_chrome_converges_with_fleet() {
1009 use ishou_tokens::{FleetTheme, convergence::Guard};
1010 let chrome_theme = FleetTheme::prescribed_default();
1011 Guard::for_app("escriba-render")
1012 .expect_theme(chrome_theme)
1013 .run();
1014 }
1015}