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 resolves through
27// `escriba_ui::syntax::ChromeSyntax`, NOT hikari's hardcoded `NordTheme`:
28// this face used to hold one by value, so picking Vellum recoloured the frame
29// and left the code Nord.
30use escriba_ui::syntax::ChromeSyntax;
31use hikari_core::{Ecosystem, Rgb as HlRgb, Theme};
32
33/// Shared handle to the editor state — both the GPU renderer (reads) and
34/// the madori `on_event` callback (writes) hold one.
35pub type SharedState = Arc<Mutex<EditorState>>;
36
37/// The GPU render callback.
38///
39/// Holds a shared reference to the editor state. `render()` reads it under
40/// lock, computes a frame, releases the lock before touching the GPU to
41/// minimise contention with the event loop.
42pub struct GpuRenderer {
43 state: SharedState,
44 font_size: f32,
45 line_height: f32,
46 /// Cached font metrics — rebuilt if font_size changes.
47 metrics: Metrics,
48 /// hikari highlight registry (built once — resolves path→Highlighter).
49 eco: Ecosystem,
50 /// The refresh generation of the currently-cached text buffer — the seal
51 /// (`theory/ESCRIBA.md` §Refresh-Seal). When `EditorState::edit_gen()`
52 /// still equals this, the cached shaped buffer is reused verbatim: no
53 /// re-highlight, no re-shape. Init `u64::MAX` so the first frame always
54 /// paints.
55 last_gen: EditGen,
56 /// The shaped main-text glyphon buffer, cached across frames while the
57 /// generation is unchanged. `None` before the first paint.
58 cached_text: Option<Buffer>,
59 /// The shaped gutter and the pixel width it occupies, cached under the
60 /// SAME generation as `cached_text`. One gate for both, so a frame can
61 /// never show this scroll position's line numbers beside the previous
62 /// one's text.
63 ///
64 /// The width travels WITH the buffer rather than being recomputed at
65 /// draw time. It depends on the buffer's line count, so a frame that
66 /// reuses a cached gutter must offset its text by the width that gutter
67 /// was actually shaped at — recomputing from a line count that has since
68 /// changed is exactly how text lands on top of line numbers for one
69 /// frame after a file grows.
70 cached_gutter: Option<(Buffer, f32)>,
71 /// The incremental highlighter for the active buffer's language (M2). Held
72 /// across frames so a re-highlight re-lexes only the lines that changed
73 /// (hikari's `LineState` fixpoint, `theory/ESCRIBA.md` §X) instead of the
74 /// whole visible window. Keyed by path so a language switch rebuilds it;
75 /// `None` before the first paint.
76 highlighter: Option<(String, Box<dyn hikari_core::IncrementalHighlighter>)>,
77}
78
79impl GpuRenderer {
80 #[must_use]
81 pub fn new(state: SharedState) -> Self {
82 let font_size = 14.0;
83 let line_height = 20.0;
84 Self {
85 state,
86 font_size,
87 line_height,
88 metrics: Metrics::new(font_size, line_height),
89 eco: build_ecosystem(),
90 last_gen: EditGen(u64::MAX),
91 cached_text: None,
92 cached_gutter: None,
93 highlighter: None,
94 }
95 }
96
97 /// Point the editor at a theme — the wiring that makes
98 /// `(deftheme :preset …)` real.
99 ///
100 /// Writes THROUGH to the shared `EditorState`, which is the single
101 /// owner of the theme. A renderer-local copy would be a second answer
102 /// to "what colour is this editor", and the TUI face would not see it.
103 pub fn set_theme(&mut self, theme: ishou_tokens::FleetTheme) {
104 self.state
105 .lock()
106 .unwrap_or_else(std::sync::PoisonError::into_inner)
107 .set_theme(theme);
108 }
109
110 /// The palette currently painted with — read from the editor.
111 #[must_use]
112 pub fn chrome(&self) -> ChromePalette {
113 self.state
114 .lock()
115 .unwrap_or_else(std::sync::PoisonError::into_inner)
116 .chrome()
117 }
118
119 /// Builder form of [`Self::set_theme`].
120 #[must_use]
121 pub fn with_theme(mut self, theme: ishou_tokens::FleetTheme) -> Self {
122 self.set_theme(theme);
123 self
124 }
125
126 #[must_use]
127 pub fn with_font_size(mut self, font_size: f32, line_height: f32) -> Self {
128 self.font_size = font_size;
129 self.line_height = line_height;
130 self.metrics = Metrics::new(font_size, line_height);
131 self
132 }
133}
134
135/// The visible text, and everything that indexes INTO it.
136///
137/// One struct rather than a tuple because there are now two independent
138/// overlays keyed by byte offset into `text`, and both are built in the SAME
139/// pass that builds it — which is the property that matters. `text` is a
140/// reconstructed string (rows trimmed, char-sliced to the horizontal window,
141/// `\n`-joined), so an offset computed against anything else — the document,
142/// the previous frame — indexes the wrong characters. Carrying them together
143/// is what makes computing them apart impossible to do by accident.
144struct TextFrame {
145 /// The visible rows, joined. Every offset below indexes this.
146 text: String,
147 /// The buffer's path, which is what resolves hikari's language.
148 path: String,
149 /// Search-match byte ranges.
150 matches: Vec<(usize, usize)>,
151 /// Language-server token byte ranges and what the server says they are.
152 /// Empty when no server answered, when the answer was about another
153 /// buffer, or when the operator has typed since — all three read the same
154 /// to the painter, which then uses hikari's lexer alone.
155 lsp: Vec<(usize, usize, hikari_core::HlClass)>,
156}
157
158/// The server-declared class covering byte `at`, if any.
159///
160/// Linear on purpose: `lsp` holds one screen's worth of tokens, and
161/// [`split_on_matches`] beside it already scans its own list the same way.
162/// Making this a binary search would add an ordering precondition to a list
163/// whose ordering is not this function's to guarantee.
164fn class_at(
165 at: usize,
166 lsp: &[(usize, usize, hikari_core::HlClass)],
167) -> Option<hikari_core::HlClass> {
168 lsp.iter()
169 .find(|&&(a, b, _)| a <= at && at < b)
170 .map(|&(_, _, c)| c)
171}
172
173/// What one piece of the final partition is painted as.
174///
175/// A search match is NOT a `HlClass` and must not be modelled as one: it is a
176/// transient UI affordance that outranks meaning, so folding it into the
177/// syntax vocabulary would let a theme rebinding change what "you are looking
178/// at a hit" looks like, and let a lexer class accidentally claim it.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum Paint {
181 /// Meaning — from the language server when it claimed this span, from
182 /// hikari's lexer otherwise.
183 Class(hikari_core::HlClass),
184 /// A live search hit. Wins over everything underneath it.
185 SearchMatch,
186}
187
188/// Cut one hikari span into painted pieces: the LSP overlay recolours it, the
189/// search overlay then wins over whatever is underneath.
190///
191/// **Extracted rather than left inline**, per this face's standing rule: logic
192/// that can be WRONG belongs outside `render()`, which needs a live wgpu
193/// device and cannot run under `cargo test`. A mis-composed partition here
194/// renders perfectly — glyphon shapes whatever runs it is handed — so the only
195/// place the check can live is a test of this function.
196///
197/// The result is contiguous, in order, and exactly covers `span`. That is not
198/// incidental: [`set_rich_text`](glyphon::Buffer::set_rich_text) is fed the
199/// concatenation of these across every span and a gap or an overlap garbles
200/// the text rather than failing. Two `split_on_matches` passes are what
201/// preserve it — splitting a coverage-complete partition yields another one,
202/// so composing the passes cannot lose the property, where a bespoke three-way
203/// splitter would be a second chance to lose it.
204///
205/// `lsp` is `(start, end, class)` byte ranges; `matches` is `(start, end)`.
206/// Both index the same string `span` does.
207#[must_use]
208pub fn paint_pieces(
209 span: std::ops::Range<usize>,
210 lexer: hikari_core::HlClass,
211 lsp: &[(usize, usize, hikari_core::HlClass)],
212 matches: &[(usize, usize)],
213) -> Vec<(std::ops::Range<usize>, Paint)> {
214 let lsp_bounds: Vec<(usize, usize)> = lsp.iter().map(|&(a, b, _)| (a, b)).collect();
215 split_on_matches(span, &lsp_bounds)
216 .into_iter()
217 .flat_map(|(piece, is_token)| {
218 // The server's word for this piece if it claimed one, hikari's
219 // otherwise. A token type escriba has no class for never reaches
220 // here — it was dropped at the decode — so the lexer's answer
221 // survives rather than being overwritten with a guess.
222 let class = if is_token {
223 class_at(piece.start, lsp).unwrap_or(lexer)
224 } else {
225 lexer
226 };
227 split_on_matches(piece, matches)
228 .into_iter()
229 .map(move |(r, is_match)| {
230 (
231 r,
232 if is_match {
233 Paint::SearchMatch
234 } else {
235 Paint::Class(class)
236 },
237 )
238 })
239 .collect::<Vec<_>>()
240 })
241 .collect()
242}
243
244impl RenderCallback for GpuRenderer {
245 fn render(&mut self, ctx: &mut RenderContext<'_>) {
246 // ── 1. Read state under lock. The visible text is built ONLY when a
247 // rebuild is due (the refresh-generation gate): an idle frame reads
248 // just mode/cursor for the status line and reuses the cached shaped
249 // buffer below — zero re-highlight, zero re-shape. `rebuild_input`
250 // is `Some(TextFrame)` exactly when the generation moved.
251 let (rebuild_input, gutter_rows, splash_chunks, mode, status_core, cur_gen, palette) = {
252 let s = self
253 .state
254 .lock()
255 .unwrap_or_else(std::sync::PoisonError::into_inner);
256 let Some(buf) = s.buffers.get(s.active) else {
257 return clear_frame(ctx);
258 };
259 let cur_gen = s.edit_gen();
260 let rebuild = cur_gen != self.last_gen || self.cached_text.is_none();
261 // The start screen replaces the buffer text entirely, so when
262 // one is up the (expensive) highlight+slice pass below is not
263 // merely wasted, it is wrong — it would paint the scratch
264 // buffer underneath. Laid out in CELLS, from the same estimate
265 // `resize` uses, so the screen centres on what is really there.
266 let splash_chunks = (rebuild)
267 .then(|| s.splash())
268 .flatten()
269 .map(|sp| {
270 let grid = cell_grid(ctx.width, ctx.height, self.font_size, self.line_height);
271 sp.screen_chunks(grid.cols, grid.rows)
272 })
273 .filter(|c| !c.is_empty());
274 let rebuild = rebuild && splash_chunks.is_none();
275 // The rendered text and both overlays that index it — see
276 // [`TextFrame`] for why they travel together.
277 let rebuild_input: Option<TextFrame> = if rebuild {
278 // The open file's path drives hikari language resolution.
279 let path = buf
280 .path
281 .as_ref()
282 .map(|p| p.to_string_lossy().into_owned())
283 .unwrap_or_default();
284 let win = s.layout.active_window().cloned();
285 let top_line = win.as_ref().map_or(0, |w| w.viewport.top_line);
286 let left_column = win.as_ref().map_or(0, |w| w.viewport.left_column) as usize;
287 let visible_lines = win
288 .as_ref()
289 .map_or(40, |w| w.viewport.visible_lines.max(20));
290 let visible_columns = win
291 .as_ref()
292 .map_or(usize::MAX, |w| w.viewport.visible_columns as usize);
293 let mut out = String::new();
294 // Search matches are DOCUMENT char offsets; `out` is a
295 // RECONSTRUCTED string (each row trimmed of \r\n, char-sliced
296 // to the horizontal window, then \n-joined). There is
297 // therefore NO single base offset relating the two — the map
298 // has to be built per row, while we still know what each row
299 // corresponds to. Converting here, at the one place both
300 // coordinate systems are in scope, is what keeps byte/char
301 // confusion out of the painting code below.
302 let mut match_bytes: Vec<(usize, usize)> = Vec::new();
303 let hl = s.search.highlights();
304 // What the language server said, for THIS buffer, at THIS
305 // revision — the accessor answers empty for any other case,
306 // so there is nothing to re-check here. Columns are `char`s
307 // within a document line (the conversion from LSP's UTF-16
308 // happened at the boundary), which is the same scale
309 // `left_column` counts in.
310 let lsp_spans = s.semantic_spans(s.active);
311 let mut lsp_bytes: Vec<(usize, usize, hikari_core::HlClass)> = Vec::new();
312 // A cursor into `lsp_spans`, advanced monotonically as the
313 // rows do. Sound because LSP's delta encoding carries UNSIGNED
314 // line deltas, so a decoded token list cannot go backwards in
315 // line order — the sortedness is structural, not a promise
316 // some server might break.
317 let mut si = 0usize;
318 for row in 0..visible_lines {
319 let ln = top_line + row;
320 if ln >= buf.line_count() {
321 break;
322 }
323 if let Some(line) = buf.line(ln) {
324 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
325 // Slice to the visible horizontal window
326 // `[left_column, left_column + visible_columns)`.
327 // Char-based so multibyte text stays aligned; long
328 // lines clip to the window, no glyphon wrap.
329 let sliced: String = trimmed
330 .chars()
331 .skip(left_column)
332 .take(visible_columns)
333 .collect();
334 let seg_byte0 = out.len();
335 let seg_chars = sliced.chars().count();
336 // char index -> byte index within this segment. Built
337 // ONCE and shared by both overlays: it used to live
338 // inside the search branch, and a second copy for the
339 // token overlay is exactly how two overlays start
340 // disagreeing about where a character is.
341 let bytes: Vec<usize> = if hl.is_empty() && lsp_spans.is_empty() {
342 Vec::new()
343 } else {
344 sliced
345 .char_indices()
346 .map(|(b, _)| b)
347 .chain(std::iter::once(sliced.len()))
348 .collect()
349 };
350 if !hl.is_empty() {
351 // Document char span this rendered segment covers.
352 let doc0 = buf
353 .position_to_char(escriba_core::Position::new(ln, 0))
354 .unwrap_or(0)
355 + left_column;
356 for m in hl {
357 let a = m.start.max(doc0);
358 let b = m.end.min(doc0 + seg_chars);
359 if a < b {
360 match_bytes.push((
361 seg_byte0 + bytes[a - doc0],
362 seg_byte0 + bytes[b - doc0],
363 ));
364 }
365 }
366 }
367 // The token overlay, clipped to the horizontal window
368 // the same way the search overlay is. A token whose
369 // start is scrolled off the left keeps its visible
370 // tail coloured rather than vanishing.
371 while si < lsp_spans.len() && lsp_spans[si].line < ln {
372 si += 1;
373 }
374 let mut sj = si;
375 while sj < lsp_spans.len() && lsp_spans[sj].line == ln {
376 let sp = &lsp_spans[sj];
377 sj += 1;
378 let a = (sp.start_char as usize).max(left_column);
379 let b = (sp.start_char as usize + sp.len_chars as usize)
380 .min(left_column + seg_chars);
381 if a < b {
382 lsp_bytes.push((
383 seg_byte0 + bytes[a - left_column],
384 seg_byte0 + bytes[b - left_column],
385 sp.class,
386 ));
387 }
388 }
389 out.push_str(&sliced);
390 out.push('\n');
391 }
392 }
393 Some(TextFrame {
394 text: out,
395 path,
396 matches: match_bytes,
397 lsp: lsp_bytes,
398 })
399 } else {
400 None
401 };
402 // The gutter's rows, gathered under the SAME lock and the same
403 // rebuild gate as the text they sit beside. Computing them in a
404 // second pass would let the two disagree about which lines are on
405 // screen — a mark one row off its finding is worse than no mark.
406 #[allow(clippy::type_complexity)]
407 let gutter_rows: Option<(
408 u32,
409 Vec<(u32, escriba_ui::gutter::GutterMarks)>,
410 )> = rebuild_input.is_some().then(|| {
411 let win = s.layout.active_window().cloned();
412 let top_line = win.as_ref().map_or(0, |w| w.viewport.top_line);
413 let visible_lines = win
414 .as_ref()
415 .map_or(40, |w| w.viewport.visible_lines.max(20));
416 let world = s.world();
417 let rows = (0..visible_lines)
418 .map(|row| top_line + row)
419 .take_while(|ln| *ln < buf.line_count())
420 .map(|ln| (ln, s.gutter_marks(&world, s.active, ln)))
421 .collect();
422 (buf.line_count(), rows)
423 });
424 (
425 rebuild_input,
426 gutter_rows,
427 splash_chunks,
428 s.modal.mode(),
429 s.status_model().render(),
430 cur_gen,
431 s.chrome(),
432 )
433 };
434
435 // ── 2. Rebuild the shaped main-text buffer ONLY on a generation
436 // change; otherwise reuse the cached one. This is the seal
437 // (theory/ESCRIBA.md §Refresh-Seal): highlight + set_rich_text +
438 // shape — the frame's dominant cost — run once per edit, never
439 // per vsync.
440 let fg = chrome_glyph(palette.text);
441 let width = ctx.width as f32;
442 let height = ctx.height as f32 - self.line_height; // reserve bottom row for status
443 if let Some(chunks) = splash_chunks {
444 // The start screen: same laid-out stream the ANSI face
445 // consumes, roles turned into glyphon attrs instead of SGR.
446 let base = Attrs::new().family(Family::Monospace);
447 let mut buffer = Buffer::new(&mut ctx.text.font_system, self.metrics);
448 buffer.set_size(&mut ctx.text.font_system, Some(width), Some(height));
449 let runs: Vec<(&str, Attrs)> = splash_runs(&chunks, &palette)
450 .into_iter()
451 .map(|(text, color)| (text, base.clone().color(color)))
452 .collect();
453 buffer.set_rich_text(
454 &mut ctx.text.font_system,
455 runs,
456 &base,
457 Shaping::Advanced,
458 None,
459 );
460 buffer.shape_until_scroll(&mut ctx.text.font_system, false);
461 self.cached_text = Some(buffer);
462 // The start screen has no gutter — it is not a view of a file.
463 // Dropping the cached one matters: without this, dismissing a file
464 // and returning to the splash would leave the last file's line
465 // numbers painted down its left edge.
466 self.cached_gutter = None;
467 self.last_gen = cur_gen;
468 } else if let Some(TextFrame {
469 text,
470 path,
471 matches: match_bytes,
472 lsp: lsp_bytes,
473 }) = rebuild_input
474 {
475 let mut buffer = Buffer::new(&mut ctx.text.font_system, self.metrics);
476 buffer.set_size(&mut ctx.text.font_system, Some(width), Some(height));
477 // hikari: resolve the language, highlight the visible text, paint
478 // each span its Nord color. The span vec is a coverage-complete,
479 // non-overlapping, sorted partition of `text` (the SpanSink
480 // invariant), so each (slice, color) run is a valid set_rich_text
481 // item. Offsets are self-consistent (highlight == render string).
482 let base = Attrs::new().family(Family::Monospace);
483 // hikari incremental (M2): reuse the per-path LineCache and re-lex
484 // only the lines that changed since the last frame (the LineState
485 // fixpoint). A language switch (path change) rebuilds the cache; a
486 // scroll re-lexes the newly-visible window (graceful degrade). This
487 // is byte-identical to the one-shot highlighter it replaces.
488 if self.highlighter.as_ref().is_none_or(|(p, _)| p != &path) {
489 self.highlighter = Some((
490 path.clone(),
491 self.eco.incremental_highlighter_for_path(&path),
492 ));
493 }
494 let hl = &mut self
495 .highlighter
496 .as_mut()
497 .expect("highlighter set immediately above")
498 .1;
499 let spans = hl.highlight(&text);
500 // Overlay search matches on the syntax partition. Each syntax
501 // span is cut at any match boundary crossing it and the matched
502 // piece is recoloured; the result is still coverage-complete,
503 // non-overlapping and sorted, which is what set_rich_text
504 // requires — splitting a partition preserves that, replacing it
505 // would not.
506 let search_color = chrome_glyph(palette.warning);
507 // The code's colours come from the SAME palette as the chrome's,
508 // so a `(deftheme :preset …)` recolours both together. Built here
509 // rather than held on the renderer: a stored copy would be one
510 // more thing to remember to update on a theme change, and the
511 // last one that was stored is exactly why code stayed Nord.
512 let syntax_theme = ChromeSyntax::new(palette);
513 // The LSP overlay is a SECOND cut of the same kind, applied before
514 // search so search still wins the pixel. All of that composition
515 // lives in `paint_pieces` — pure, and therefore testable, which is
516 // the only place a mis-composed partition can be caught: glyphon
517 // shapes whatever runs it is handed and renders a wrong one
518 // perfectly.
519 let runs: Vec<(&str, Attrs)> = spans
520 .iter()
521 .flat_map(|sp| {
522 paint_pieces(sp.span.range(), sp.class, &lsp_bytes, &match_bytes)
523 .into_iter()
524 .filter_map(|(r, paint)| {
525 text.get(r).map(|slice| {
526 (
527 slice,
528 match paint {
529 Paint::SearchMatch => base.clone().color(search_color),
530 Paint::Class(c) => {
531 base.clone().color(hl_to_glyph(syntax_theme.color(c)))
532 }
533 },
534 )
535 })
536 })
537 .collect::<Vec<_>>()
538 })
539 .collect();
540 buffer.set_rich_text(
541 &mut ctx.text.font_system,
542 runs,
543 &base,
544 Shaping::Advanced,
545 None,
546 );
547 buffer.shape_until_scroll(&mut ctx.text.font_system, false);
548 self.cached_text = Some(buffer);
549 self.last_gen = cur_gen;
550 }
551 // ── 2b. The gutter, shaped as its OWN glyphon buffer.
552 //
553 // Separate rather than prefixed into the text, and this is the load-
554 // bearing reason: the syntax spans and the search-match ranges are
555 // BYTE offsets into `out`. Prefixing each line with `" 12 │ "` would
556 // shift every one of those offsets, so the highlighter would paint the
557 // wrong spans and search would box the wrong characters. Two areas
558 // keeps one coordinate system per buffer.
559 if let Some((line_count, rows)) = gutter_rows {
560 let base = Attrs::new().family(Family::Monospace);
561 let muted = chrome_glyph(palette.text_dim);
562 let gutter_w = gutter_px(self.font_size, line_count);
563 let mut gutter_buf = Buffer::new(&mut ctx.text.font_system, self.metrics);
564 gutter_buf.set_size(&mut ctx.text.font_system, Some(gutter_w), Some(height));
565 // Owned strings first: `set_rich_text` borrows its slices, so the
566 // runs cannot reference temporaries created inside the same call.
567 let mut owned: Vec<(String, GlyphColor)> = Vec::with_capacity(rows.len() * 5);
568 for (ln, marks) in &rows {
569 for cell in escriba_ui::gutter::gutter_cells(*ln, *marks, line_count) {
570 let color = match cell.role {
571 escriba_ui::gutter::GutterRole::Mark(sev) => {
572 chrome_glyph(escriba_ui::chrome::severity_color(&palette, sev))
573 }
574 escriba_ui::gutter::GutterRole::Breakpoint => {
575 chrome_glyph(escriba_ui::chrome::breakpoint_color(&palette))
576 }
577 _ => muted,
578 };
579 owned.push((cell.text, color));
580 }
581 owned.push(("\n".to_string(), muted));
582 }
583 let runs: Vec<(&str, Attrs)> = owned
584 .iter()
585 .map(|(t, c)| (t.as_str(), base.clone().color(*c)))
586 .collect();
587 gutter_buf.set_rich_text(
588 &mut ctx.text.font_system,
589 runs,
590 &base,
591 Shaping::Advanced,
592 None,
593 );
594 gutter_buf.shape_until_scroll(&mut ctx.text.font_system, false);
595 self.cached_gutter = Some((gutter_buf, gutter_w));
596 }
597
598 let buffer = self
599 .cached_text
600 .as_ref()
601 .expect("cached_text is built on the first frame (last_gen inits to u64::MAX)");
602
603 // Status line — rendered as its own glyphon buffer. The mode is the
604 // BORN fleet mode glyph (`ishou_tokens::EscribaSignals`) + escriba's
605 // canonical uppercase mode label.
606 let signals = EscribaSignals::prescribed();
607 // Built from `EditorState::status_model()` — the ONE model the
608 // ratatui face renders too, so the two can differ only in styling.
609 // This replaces a fixed `format!()` that carried mode/line/col/version
610 // and read neither the prompt nor any message: typing `/foo` on this
611 // face moved the cursor with nothing on screen to show for it, which
612 // is why search looked absent on escriba's default renderer.
613 //
614 // `push_str`, not `format!` — ★★ TYPED EMISSION.
615 let mut status = String::with_capacity(status_core.len() + 24);
616 status.push(' ');
617 status.push_str(mode_glyph(&signals, mode).render(SignalMode::Glyph));
618 status.push(' ');
619 status.push_str(&status_core);
620 status.push_str(" escriba v");
621 status.push_str(env!("CARGO_PKG_VERSION"));
622 status.push(' ');
623 let mut status_buf = Buffer::new(&mut ctx.text.font_system, self.metrics);
624 status_buf.set_size(
625 &mut ctx.text.font_system,
626 Some(width),
627 Some(self.line_height * 2.0),
628 );
629 status_buf.set_text(
630 &mut ctx.text.font_system,
631 &status,
632 &Attrs::new().family(Family::Monospace),
633 Shaping::Advanced,
634 );
635 status_buf.shape_until_scroll(&mut ctx.text.font_system, false);
636
637 let status_color = chrome_glyph(palette.info);
638
639 // The text starts AFTER the gutter when there is one, and at the left
640 // margin when there is not (the start screen). Deriving the offset
641 // from `cached_gutter` rather than from a flag keeps the two from
642 // disagreeing — an indented text column with no gutter beside it would
643 // just look like a broken margin.
644 let text_left = 8.0 + self.cached_gutter.as_ref().map_or(0.0, |(_, w)| *w);
645 let mut text_areas = vec![
646 TextArea {
647 buffer,
648 left: text_left,
649 top: 8.0,
650 scale: 1.0,
651 bounds: TextBounds {
652 left: text_left as i32,
653 top: 0,
654 right: ctx.width as i32,
655 bottom: (height as i32).max(0),
656 },
657 default_color: fg,
658 custom_glyphs: &[],
659 },
660 TextArea {
661 buffer: &status_buf,
662 left: 8.0,
663 top: (ctx.height as f32 - self.line_height - 4.0).max(0.0),
664 scale: 1.0,
665 bounds: TextBounds {
666 left: 0,
667 top: (ctx.height as i32 - self.line_height as i32 - 4).max(0),
668 right: ctx.width as i32,
669 bottom: ctx.height as i32,
670 },
671 default_color: status_color,
672 custom_glyphs: &[],
673 },
674 ];
675 if let Some((g, gutter_w)) = self.cached_gutter.as_ref() {
676 text_areas.push(TextArea {
677 buffer: g,
678 left: 8.0,
679 top: 8.0,
680 scale: 1.0,
681 // Bounded to its own columns. Without this a line number
682 // wider than the field would spill into the text column and
683 // overprint the first characters of the file.
684 bounds: TextBounds {
685 left: 0,
686 top: 0,
687 right: (8.0 + gutter_w) as i32,
688 bottom: (height as i32).max(0),
689 },
690 default_color: chrome_glyph(palette.text_dim),
691 custom_glyphs: &[],
692 });
693 }
694
695 if let Err(e) = ctx.text.prepare(
696 &ctx.gpu.device,
697 &ctx.gpu.queue,
698 ctx.width,
699 ctx.height,
700 text_areas,
701 ) {
702 tracing::warn!(error = %e, "glyphon prepare failed");
703 return clear_frame(ctx);
704 }
705
706 // ── 3. Encode frame. ───────────────────────────────────────────
707 let mut encoder = ctx
708 .gpu
709 .device
710 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
711 label: Some("escriba frame"),
712 });
713 {
714 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
715 label: Some("escriba main pass"),
716 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
717 view: ctx.surface_view,
718 resolve_target: None,
719 ops: wgpu::Operations {
720 load: wgpu::LoadOp::Clear(ground_bg(&palette)),
721 store: wgpu::StoreOp::Store,
722 },
723 })],
724 depth_stencil_attachment: None,
725 timestamp_writes: None,
726 occlusion_query_set: None,
727 });
728 if let Err(e) = ctx.text.render(&mut pass) {
729 tracing::warn!(error = %e, "glyphon render failed");
730 }
731 }
732 ctx.gpu.queue.submit(std::iter::once(encoder.finish()));
733 }
734
735 fn resize(&mut self, width: u32, height: u32) {
736 if let Ok(mut s) = self.state.lock() {
737 // The SAME grid the start screen is laid out on — one estimate,
738 // one status-row reservation, one place to fix either.
739 let grid = cell_grid(width, height, self.font_size, self.line_height);
740 for w in s.layout.windows_mut() {
741 w.viewport.visible_lines = u32::from(grid.rows);
742 // The full grid, NOT minus the gutter. The gutter's width
743 // depends on the buffer's line count, which `resize` has no
744 // business knowing; the subtraction happens in `render`,
745 // where the buffer is in scope. Reserving a guessed width
746 // here would be wrong for every file but one.
747 w.viewport.visible_columns = u32::from(grid.cols);
748 }
749 }
750 }
751}
752
753/// The highlight registry — re-exported from `escriba-ts`, where it now
754/// lives. It was defined HERE, which put escriba's language knowledge behind
755/// a GPU dependency; the re-export keeps this face's call sites and its tests
756/// unchanged while the runtime can now reach the same registry without wgpu.
757pub use escriba_ts::build_ecosystem;
758
759/// Pair each start-screen chunk with the colour its ROLE resolves to under
760/// `palette` — the GPU face's half of the role→paint mapping, extracted so
761/// it can be tested without a device.
762///
763/// This is the piece of the splash path that can be wrong in a way glyphon
764/// would not notice: a mis-mapped role paints the menu keys as body text and
765/// renders perfectly. The plumbing either side (buffer sizing, shaping) is
766/// upstream's contract; this is ours.
767///
768/// Borrows from `chunks`, so the returned slices concatenate to exactly the
769/// screen — the coverage-complete partition `set_rich_text` requires.
770///
771/// Public so `tests/gpu_logic.rs` can assert on the REAL mapping rather
772/// than on a reconstruction of it; a test that rebuilt this from
773/// `screen_chunks` would pass even if the renderer stopped calling it.
774#[must_use]
775pub fn splash_runs<'a>(
776 chunks: &'a [escriba_ui::splash::SplashSpan],
777 palette: &ChromePalette,
778) -> Vec<(&'a str, GlyphColor)> {
779 chunks
780 .iter()
781 .map(|c| (c.text.as_str(), chrome_glyph(c.role.color(palette))))
782 .collect()
783}
784
785/// The gutter's width in PIXELS for a buffer of `line_count` lines.
786///
787/// Uses the same `MONO_ADVANCE_RATIO` estimate `cell_grid` does — so the
788/// gutter and the text agree about how wide a column is, and the text starts
789/// exactly where the gutter stops. The column count comes from
790/// `escriba_ui::gutter::gutter_width`, never restated here: the number of
791/// columns this face RESERVES and the number the shared model PAINTS have to
792/// be the same number, and a second definition is how they stop being.
793#[must_use]
794pub fn gutter_px(font_size: f32, line_count: u32) -> f32 {
795 (font_size * MONO_ADVANCE_RATIO).max(1.0) * escriba_ui::gutter::gutter_width(line_count) as f32
796}
797
798/// The character grid a pixel surface maps to.
799///
800/// Both the viewport (how many buffer lines and columns fit) and the start
801/// screen (what canvas to centre on) need this, and they used to compute it
802/// separately: `resize` divided height by line-height and subtracted a row,
803/// `render` subtracted a line-height and then divided. Same intent, two
804/// spellings, two places to get the status-row reservation wrong.
805///
806/// Pure and total — no GPU, no state — which is what makes the one piece of
807/// arithmetic in the GPU face that can actually be WRONG testable without a
808/// device. The `0.6` is glyphon's monospace advance ratio for
809/// `Family::Monospace`: an estimate, and the honest reason the start screen
810/// centres approximately rather than exactly.
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812pub struct CellGrid {
813 pub cols: u16,
814 pub rows: u16,
815}
816
817/// Advance-to-font-size ratio for glyphon's monospace face.
818const MONO_ADVANCE_RATIO: f32 = 0.6;
819
820#[must_use]
821pub fn cell_grid(width_px: u32, height_px: u32, font_size: f32, line_height: f32) -> CellGrid {
822 let cell_w = (font_size * MONO_ADVANCE_RATIO).max(1.0);
823 let cell_h = line_height.max(1.0);
824 let cols = (width_px as f32 / cell_w).floor().max(1.0);
825 // One row is reserved for the status line, which is drawn as its own
826 // text area below the main pane. Reserved ONCE, here, so no caller can
827 // forget it or subtract it twice.
828 let rows = (height_px as f32 / cell_h).floor().max(2.0) - 1.0;
829 CellGrid {
830 cols: cols.min(f32::from(u16::MAX)) as u16,
831 rows: rows.min(f32::from(u16::MAX)) as u16,
832 }
833}
834
835/// Utility — clear the frame to the ground colour. Used on error paths.
836///
837/// This one legitimately paints the FLEET-PRESCRIBED ground rather than the
838/// operator's: it runs when the editor state could not be read (no active
839/// buffer, a failed glyphon prepare), which is exactly when the operator's
840/// theme is unknowable. A dark frame in the default theme beats a panic or
841/// an undefined surface.
842fn clear_frame(ctx: &mut RenderContext<'_>) {
843 let palette = ChromePalette::prescribed();
844 let mut encoder = ctx
845 .gpu
846 .device
847 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
848 label: Some("escriba clear"),
849 });
850 {
851 let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
852 label: Some("escriba clear pass"),
853 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
854 view: ctx.surface_view,
855 resolve_target: None,
856 ops: wgpu::Operations {
857 load: wgpu::LoadOp::Clear(ground_bg(&palette)),
858 store: wgpu::StoreOp::Store,
859 },
860 })],
861 depth_stencil_attachment: None,
862 timestamp_writes: None,
863 occlusion_query_set: None,
864 });
865 }
866 ctx.gpu.queue.submit(std::iter::once(encoder.finish()));
867}
868
869/// The editor ground as `wgpu::Color`, resolved from the fleet-prescribed
870/// theme's `background` role. Gamma-correct: the sRGB token is promoted
871/// through `ishou_tokens`' typed sRGB→linear path so it composites
872/// correctly on the linear-storage surface.
873fn ground_bg(c: &ChromePalette) -> wgpu::Color {
874 Srgb::from(c.background).to_linear().with_alpha(1.0).into()
875}
876
877/// ishou `Rgb` → glyphon `Color` (sRGB u8 RGBA, opaque). Theme-agnostic —
878/// was `vellum_glyph`, back when the paint path was hardwired to Vellum.
879/// Cut `range` wherever a match in `matches` starts or ends inside it.
880///
881/// Returns `(sub_range, is_match)` pieces that are contiguous, in order, and
882/// exactly cover `range` — the property `set_rich_text` depends on. `matches`
883/// are byte ranges into the SAME string `range` indexes.
884///
885/// Splitting the existing syntax partition (rather than building a second one)
886/// is what keeps the two colour sources composable: a match inside a string
887/// literal recolours only the matched bytes and the literal keeps its colour
888/// either side.
889fn split_on_matches(
890 range: std::ops::Range<usize>,
891 matches: &[(usize, usize)],
892) -> Vec<(std::ops::Range<usize>, bool)> {
893 let mut cuts: Vec<usize> = vec![range.start, range.end];
894 for &(a, b) in matches {
895 if a > range.start && a < range.end {
896 cuts.push(a);
897 }
898 if b > range.start && b < range.end {
899 cuts.push(b);
900 }
901 }
902 if cuts.len() == 2 {
903 // No boundary crosses this span — the common case, so avoid the
904 // sort/dedup entirely.
905 let hit = matches
906 .iter()
907 .any(|&(a, b)| a <= range.start && b >= range.end);
908 return vec![(range, hit)];
909 }
910 cuts.sort_unstable();
911 cuts.dedup();
912 cuts.windows(2)
913 .map(|w| {
914 let (a, b) = (w[0], w[1]);
915 let hit = matches.iter().any(|&(ms, me)| ms <= a && me >= b);
916 (a..b, hit)
917 })
918 .collect()
919}
920
921fn chrome_glyph(c: Rgb) -> GlyphColor {
922 GlyphColor::rgba(c.r, c.g, c.b, 0xFF)
923}
924
925/// hikari `Rgb` (sRGB u8) → glyphon `Color` (opaque) — the syntax-span paint.
926fn hl_to_glyph(c: HlRgb) -> GlyphColor {
927 GlyphColor::rgba(c.r, c.g, c.b, 0xFF)
928}
929
930/// Mode indicator color — used by higher-layer rendering paths that want a
931/// glance-readable color. Named by ROLE so the hue follows the active theme:
932/// Normal info, Insert success, Visual accent, Command warning.
933#[must_use]
934pub fn mode_color(c: &ChromePalette, mode: Mode) -> Rgb {
935 match mode {
936 Mode::Insert => c.success,
937 Mode::Command => c.warning,
938 Mode::Visual | Mode::VisualLine => c.accent,
939 Mode::Normal => c.info,
940 }
941}
942
943/// The [`CursorShape`](escriba_core::CursorShape) the GPU backend should
944/// draw for `mode`. Derived from the single typed `Mode::cursor_shape`
945/// mapping shared with the TUI backend — so the GPU cursor (once it gains a
946/// dedicated glyph; today the buffer text carries the caret) renders the
947/// same shape the TUI does for any given mode. Exposed now so the shape is
948/// a typed value at the GPU layer, not a renderer-local literal later.
949#[must_use]
950pub fn cursor_shape(mode: Mode) -> escriba_core::CursorShape {
951 mode.cursor_shape()
952}
953
954/// Map an editor [`Mode`] to its fleet [`Signal`](ishou_tokens::Signal)
955/// from [`EscribaSignals`].
956///
957/// `VisualLine` shares `mode_visual` with `Visual` — the fleet signal set
958/// has one visual signal, matching how [`mode_color`] groups the two.
959#[must_use]
960pub fn mode_glyph(sig: &EscribaSignals, mode: Mode) -> &ishou_tokens::Signal {
961 match mode {
962 Mode::Normal => &sig.mode_normal,
963 Mode::Insert => &sig.mode_insert,
964 Mode::Visual | Mode::VisualLine => &sig.mode_visual,
965 Mode::Command => &sig.mode_command,
966 }
967}
968
969#[cfg(test)]
970mod tests {
971
972 // ── search-highlight overlay ──────────────────────────────────────
973 //
974 // set_rich_text requires a coverage-complete, non-overlapping, sorted
975 // partition. Splitting the syntax partition preserves that; these pin it,
976 // because a violation shows up as garbled text rather than a panic.
977
978 /// The invariant, asserted directly: pieces are contiguous, ordered, and
979 /// exactly cover the input range.
980 fn assert_partition(range: std::ops::Range<usize>, out: &[(std::ops::Range<usize>, bool)]) {
981 assert!(!out.is_empty(), "a range must yield at least one piece");
982 assert_eq!(out[0].0.start, range.start, "starts at the range start");
983 assert_eq!(out[out.len() - 1].0.end, range.end, "ends at the range end");
984 for w in out.windows(2) {
985 assert_eq!(
986 w[0].0.end, w[1].0.start,
987 "pieces are contiguous, no gap or overlap"
988 );
989 }
990 }
991
992 #[test]
993 fn a_span_with_no_match_is_returned_whole() {
994 let out = split_on_matches(0..10, &[]);
995 assert_eq!(out.len(), 1, "no needless splitting");
996 assert!(!out[0].1);
997 assert_partition(0..10, &out);
998 }
999
1000 #[test]
1001 fn a_match_covering_the_whole_span_marks_it_without_splitting() {
1002 let out = split_on_matches(4..8, &[(0, 20)]);
1003 assert_eq!(out.len(), 1);
1004 assert!(out[0].1, "fully covered span is a match");
1005 assert_partition(4..8, &out);
1006 }
1007
1008 #[test]
1009 fn a_match_starting_mid_span_splits_it_in_two() {
1010 // Syntax span 0..10, match 5..10 -> [0..5 plain][5..10 match]
1011 let out = split_on_matches(0..10, &[(5, 10)]);
1012 assert_eq!(out.len(), 2);
1013 assert_eq!(out[0], (0..5, false));
1014 assert_eq!(out[1], (5..10, true));
1015 assert_partition(0..10, &out);
1016 }
1017
1018 #[test]
1019 fn a_match_inside_a_span_splits_it_in_three() {
1020 // This is the case that matters: a match inside a string literal must
1021 // recolour only the matched bytes, leaving the literal coloured
1022 // either side.
1023 let out = split_on_matches(0..10, &[(3, 6)]);
1024 assert_eq!(out.len(), 3);
1025 assert_eq!(out[0], (0..3, false));
1026 assert_eq!(out[1], (3..6, true));
1027 assert_eq!(out[2], (6..10, false));
1028 assert_partition(0..10, &out);
1029 }
1030
1031 #[test]
1032 fn two_matches_in_one_span_both_split() {
1033 let out = split_on_matches(0..20, &[(2, 4), (10, 12)]);
1034 assert_partition(0..20, &out);
1035 let hits: Vec<_> = out
1036 .iter()
1037 .filter(|(_, m)| *m)
1038 .map(|(r, _)| r.clone())
1039 .collect();
1040 assert_eq!(hits, vec![2..4, 10..12]);
1041 }
1042
1043 #[test]
1044 fn a_match_entirely_outside_the_span_changes_nothing() {
1045 let out = split_on_matches(10..20, &[(0, 5)]);
1046 assert_eq!(out.len(), 1);
1047 assert!(!out[0].1);
1048 assert_partition(10..20, &out);
1049 }
1050
1051 #[test]
1052 fn a_match_touching_the_span_edge_does_not_create_an_empty_piece() {
1053 // Boundary exactly at the edge must not emit a zero-width run.
1054 for m in [(0usize, 10usize), (10, 20)] {
1055 let out = split_on_matches(10..20, &[m]);
1056 assert_partition(10..20, &out);
1057 assert!(
1058 out.iter().all(|(r, _)| r.start < r.end),
1059 "no empty piece for {m:?}"
1060 );
1061 }
1062 }
1063
1064 #[test]
1065 fn adjacent_matches_do_not_produce_duplicate_cuts() {
1066 // Two matches meeting at 5 must yield one cut there, not two.
1067 let out = split_on_matches(0..10, &[(0, 5), (5, 10)]);
1068 assert_partition(0..10, &out);
1069 assert!(out.iter().all(|(r, _)| r.start < r.end));
1070 assert!(out.iter().all(|(_, m)| *m), "both halves are matches");
1071 }
1072 use super::*;
1073 use escriba_buffer::BufferSet;
1074
1075 #[test]
1076 fn ground_is_the_prescribed_theme_promoted_to_linear() {
1077 let bg = ground_bg(&ChromePalette::prescribed());
1078 // Was pinned to Vellum's warm parchment (night0 #16140E, r >= g >= b).
1079 // The prescribed theme is now Nord, whose ground is COOL (b >= r), so
1080 // the old warmth assertion was theme-specific and had to go. What is
1081 // actually invariant — and worth asserting — is that the ground is a
1082 // dark, opaque, gamma-correct promotion of the theme's own
1083 // background role.
1084 let want = Srgb::from(ChromePalette::prescribed().background)
1085 .to_linear()
1086 .with_alpha(1.0);
1087 let want: wgpu::Color = want.into();
1088 assert!((bg.r - want.r).abs() < 1e-6, "r {} != {}", bg.r, want.r);
1089 assert!((bg.g - want.g).abs() < 1e-6, "g {} != {}", bg.g, want.g);
1090 assert!((bg.b - want.b).abs() < 1e-6, "b {} != {}", bg.b, want.b);
1091 assert_eq!(bg.a, 1.0);
1092 // Dark ground: an editor background must stay well below mid-grey in
1093 // linear space whatever the theme.
1094 assert!(
1095 bg.r < 0.1 && bg.g < 0.1 && bg.b < 0.1,
1096 "ground is not dark: {bg:?}"
1097 );
1098 }
1099
1100 #[test]
1101 fn renderer_construction_is_cheap() {
1102 let mut bufs = BufferSet::new();
1103 let id = bufs.scratch("hello\n");
1104 let state = Arc::new(Mutex::new(EditorState::new_with_buffer(bufs, id)));
1105 let _r = GpuRenderer::new(state);
1106 }
1107
1108 /// Phase 4: the render Ecosystem serves `.rs` from the **tree-sitter**
1109 /// backend (hikari-ts) and other languages from the table backend — both a
1110 /// coverage-complete `HlClass` partition. Proves real tree-sitter
1111 /// highlighting is wired into the live render path (not just the table lexer).
1112 #[test]
1113 fn ecosystem_uses_tree_sitter_for_rust_and_table_for_the_rest() {
1114 use hikari_core::{HlClass, Language};
1115 let eco = build_ecosystem();
1116 // .rs resolves to a grammar and produces real (non-Plain) classification.
1117 assert_eq!(eco.resolve("src/main.rs"), Language("rust"));
1118 let rs = eco
1119 .highlighter_for_path("src/main.rs")
1120 .highlight("fn main() { let x = 42; }");
1121 assert!(
1122 rs.iter().any(|s| s.class != HlClass::Plain),
1123 "rust must be really highlighted (tree-sitter or table)",
1124 );
1125 // Python is also served (tree-sitter, once hikari-ts ships that grammar;
1126 // the table backend covers it otherwise) — either way it classifies.
1127 assert_eq!(eco.resolve("app.py"), Language("python"));
1128 // A tree-sitter-uncovered language still resolves via the table backend.
1129 assert_eq!(eco.resolve("init.lisp"), Language("lisp"));
1130 // An unknown extension is still total (plain text, never a panic).
1131 assert_eq!(eco.resolve("notes.xyz"), hikari_core::PLAIN_TEXT);
1132 }
1133
1134 #[test]
1135 fn mode_colors_differ_by_mode() {
1136 let n = mode_color(&ChromePalette::prescribed(), Mode::Normal);
1137 let i = mode_color(&ChromePalette::prescribed(), Mode::Insert);
1138 let v = mode_color(&ChromePalette::prescribed(), Mode::Visual);
1139 assert_ne!((n.r, n.g, n.b), (i.r, i.g, i.b));
1140 assert_ne!((n.r, n.g, n.b), (v.r, v.g, v.b));
1141 }
1142
1143 #[test]
1144 fn cursor_shape_tracks_mode() {
1145 use escriba_core::CursorShape;
1146 assert_eq!(cursor_shape(Mode::Normal), CursorShape::Block);
1147 assert_eq!(cursor_shape(Mode::Command), CursorShape::Block);
1148 assert_eq!(cursor_shape(Mode::Insert), CursorShape::Bar);
1149 assert_eq!(cursor_shape(Mode::Visual), CursorShape::Underline);
1150 assert_eq!(cursor_shape(Mode::VisualLine), CursorShape::Underline);
1151 }
1152
1153 /// Mode pills map to ROLES, not to one theme's hexes. This test used to
1154 /// pin the four Vellum values (`#94BBB8` …), which is precisely why it
1155 /// went red the moment the fleet theme moved — a test asserting a
1156 /// theme's spelling has to be rewritten on every theme change, and is
1157 /// no evidence the mapping is right. Asserting role identity instead
1158 /// survives the move AND still catches a mis-wired pill.
1159 #[test]
1160 fn mode_colors_are_role_pills() {
1161 let c = ChromePalette::prescribed();
1162 assert_eq!(
1163 mode_color(&ChromePalette::prescribed(), Mode::Normal).hex(),
1164 c.info.hex(),
1165 "Normal = info"
1166 );
1167 assert_eq!(
1168 mode_color(&ChromePalette::prescribed(), Mode::Insert).hex(),
1169 c.success.hex(),
1170 "Insert = success"
1171 );
1172 assert_eq!(
1173 mode_color(&ChromePalette::prescribed(), Mode::Visual).hex(),
1174 c.accent.hex(),
1175 "Visual = accent"
1176 );
1177 assert_eq!(
1178 mode_color(&ChromePalette::prescribed(), Mode::Command).hex(),
1179 c.warning.hex(),
1180 "Command = warning"
1181 );
1182
1183 // The four pills must be mutually distinct, or the mode is not
1184 // glance-readable regardless of which theme is active.
1185 let mut seen = std::collections::BTreeSet::new();
1186 for m in [Mode::Normal, Mode::Insert, Mode::Visual, Mode::Command] {
1187 assert!(
1188 seen.insert(mode_color(&ChromePalette::prescribed(), m).hex()),
1189 "{m:?} duplicates another pill"
1190 );
1191 }
1192 }
1193
1194 /// Forcing function: the status-line mode glyphs are sourced from the
1195 /// fleet `EscribaSignals` vocabulary, not hand-picked literals. Pins
1196 /// the geometric `Glyph`-mode marks so drift in either escriba or
1197 /// ishou surfaces here.
1198 #[test]
1199 fn mode_glyphs_are_fleet_signals() {
1200 let sig = EscribaSignals::prescribed();
1201 assert_eq!(
1202 mode_glyph(&sig, Mode::Normal).render(SignalMode::Glyph),
1203 "◆"
1204 );
1205 assert_eq!(
1206 mode_glyph(&sig, Mode::Insert).render(SignalMode::Glyph),
1207 "▸"
1208 );
1209 assert_eq!(
1210 mode_glyph(&sig, Mode::Visual).render(SignalMode::Glyph),
1211 "▮"
1212 );
1213 assert_eq!(
1214 mode_glyph(&sig, Mode::VisualLine).render(SignalMode::Glyph),
1215 "▮"
1216 );
1217 assert_eq!(
1218 mode_glyph(&sig, Mode::Command).render(SignalMode::Glyph),
1219 ":"
1220 );
1221 }
1222
1223 /// Fleet convergence guard: escriba's GPU chrome paints whatever
1224 /// `ChromePalette::prescribed()` resolves, which is
1225 /// `FleetTheme::prescribed_default()` BY CONSTRUCTION — so this Guard
1226 /// cannot be satisfied by a stale hand-written constant.
1227 ///
1228 /// It previously hardcoded `FleetTheme::Vellum` to match a paint path
1229 /// hardwired to `VellumPalette::vellum()`. When the fleet moved its
1230 /// prescribed theme to PlemeDark (Nord) this went RED — correctly, since
1231 /// the GPU backend really was painting the wrong theme while the TUI
1232 /// face and the rest of the fleet (mado, tear, frostmourne, …) moved on.
1233 /// Smallest real editor state — a scratch buffer. The theming tests
1234 /// care about the palette, not the buffer, but GpuRenderer owns state.
1235 fn test_renderer() -> GpuRenderer {
1236 let mut bufs = escriba_buffer::BufferSet::new();
1237 let id = bufs.scratch("");
1238 GpuRenderer::new(Arc::new(Mutex::new(EditorState::new_with_buffer(bufs, id))))
1239 }
1240
1241 #[test]
1242 fn default_theme_is_the_fleet_prescribed_nord() {
1243 // Nord is the default because the FLEET says so — asserted against
1244 // FleetTheme::prescribed_default(), never a hand-written "nord",
1245 // so a fleet re-point cannot leave escriba silently behind.
1246 let r = test_renderer();
1247 let want = ChromePalette::for_theme(ishou_tokens::FleetTheme::prescribed_default());
1248 assert_eq!(r.chrome().hex_tuple(), want.hex_tuple());
1249 }
1250
1251 #[test]
1252 fn set_theme_actually_changes_what_is_painted() {
1253 // The wiring this exists to prove: before it, every paint site
1254 // called ChromePalette::prescribed() directly, so (deftheme :preset)
1255 // resolved to a real FleetTheme that NOTHING consumed. If set_theme
1256 // ever stops reaching the paint path, this fails.
1257 let mut r = test_renderer();
1258 let before = r.chrome().hex_tuple();
1259 r.set_theme(ishou_tokens::FleetTheme::Vellum);
1260 let after = r.chrome().hex_tuple();
1261 assert_ne!(
1262 before, after,
1263 "switching to Vellum must change the painted palette"
1264 );
1265 assert_eq!(
1266 after,
1267 ChromePalette::for_theme(ishou_tokens::FleetTheme::Vellum).hex_tuple()
1268 );
1269 // And it is reversible — a theme is a value, not a one-way latch.
1270 r.set_theme(ishou_tokens::FleetTheme::prescribed_default());
1271 assert_eq!(r.chrome().hex_tuple(), before);
1272 }
1273
1274 #[test]
1275 fn escriba_gpu_chrome_converges_with_fleet() {
1276 use ishou_tokens::{FleetTheme, convergence::Guard};
1277 let chrome_theme = FleetTheme::prescribed_default();
1278 Guard::for_app("escriba-render")
1279 .expect_theme(chrome_theme)
1280 .run();
1281 }
1282}