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