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