Skip to main content

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 glyphon::{Attrs, Buffer, Color as GlyphColor, Family, Metrics, Shaping, TextArea, TextBounds};
22use escriba_ui::chrome::ChromePalette;
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            (rebuild_input, s.modal.mode(), s.status_model().render(), cur_gen)
224        };
225
226        // ── 2. Rebuild the shaped main-text buffer ONLY on a generation
227        //    change; otherwise reuse the cached one. This is the seal
228        //    (theory/ESCRIBA.md §Refresh-Seal): highlight + set_rich_text +
229        //    shape — the frame's dominant cost — run once per edit, never
230        //    per vsync.
231        let palette = self.chrome;
232        let fg = chrome_glyph(palette.text);
233        let width = ctx.width as f32;
234        let height = ctx.height as f32 - self.line_height; // reserve bottom row for status
235        if let Some((text, path, match_bytes)) = rebuild_input {
236            let mut buffer = Buffer::new(&mut ctx.text.font_system, self.metrics);
237            buffer.set_size(&mut ctx.text.font_system, Some(width), Some(height));
238            // hikari: resolve the language, highlight the visible text, paint
239            // each span its Nord color. The span vec is a coverage-complete,
240            // non-overlapping, sorted partition of `text` (the SpanSink
241            // invariant), so each (slice, color) run is a valid set_rich_text
242            // item. Offsets are self-consistent (highlight == render string).
243            let base = Attrs::new().family(Family::Monospace);
244            // hikari incremental (M2): reuse the per-path LineCache and re-lex
245            // only the lines that changed since the last frame (the LineState
246            // fixpoint). A language switch (path change) rebuilds the cache; a
247            // scroll re-lexes the newly-visible window (graceful degrade). This
248            // is byte-identical to the one-shot highlighter it replaces.
249            if self.highlighter.as_ref().is_none_or(|(p, _)| p != &path) {
250                self.highlighter =
251                    Some((path.clone(), self.eco.incremental_highlighter_for_path(&path)));
252            }
253            let hl = &mut self
254                .highlighter
255                .as_mut()
256                .expect("highlighter set immediately above")
257                .1;
258            let spans = hl.highlight(&text);
259            // Overlay search matches on the syntax partition. Each syntax
260            // span is cut at any match boundary crossing it and the matched
261            // piece is recoloured; the result is still coverage-complete,
262            // non-overlapping and sorted, which is what set_rich_text
263            // requires — splitting a partition preserves that, replacing it
264            // would not.
265            let search_color = chrome_glyph(self.chrome.warning);
266            let runs: Vec<(&str, Attrs)> = spans
267                .iter()
268                .flat_map(|sp| {
269                    let syntax = base.clone().color(hl_to_glyph(self.theme.color(sp.class)));
270                    split_on_matches(sp.span.range(), &match_bytes)
271                        .into_iter()
272                        .filter_map(|(r, is_match)| {
273                            text.get(r).map(|slice| {
274                                (
275                                    slice,
276                                    if is_match {
277                                        base.clone().color(search_color)
278                                    } else {
279                                        syntax.clone()
280                                    },
281                                )
282                            })
283                        })
284                        .collect::<Vec<_>>()
285                })
286                .collect();
287            buffer.set_rich_text(&mut ctx.text.font_system, runs, &base, Shaping::Advanced, None);
288            buffer.shape_until_scroll(&mut ctx.text.font_system, false);
289            self.cached_text = Some(buffer);
290            self.last_gen = cur_gen;
291        }
292        let buffer = self
293            .cached_text
294            .as_ref()
295            .expect("cached_text is built on the first frame (last_gen inits to u64::MAX)");
296
297        // Status line — rendered as its own glyphon buffer. The mode is the
298        // BORN fleet mode glyph (`ishou_tokens::EscribaSignals`) + escriba's
299        // canonical uppercase mode label.
300        let signals = EscribaSignals::prescribed();
301        // Built from `EditorState::status_model()` — the ONE model the
302        // ratatui face renders too, so the two can differ only in styling.
303        // This replaces a fixed `format!()` that carried mode/line/col/version
304        // and read neither the prompt nor any message: typing `/foo` on this
305        // face moved the cursor with nothing on screen to show for it, which
306        // is why search looked absent on escriba's default renderer.
307        //
308        // `push_str`, not `format!` — ★★ TYPED EMISSION.
309        let mut status = String::with_capacity(status_core.len() + 24);
310        status.push(' ');
311        status.push_str(mode_glyph(&signals, mode).render(SignalMode::Glyph));
312        status.push(' ');
313        status.push_str(&status_core);
314        status.push_str("  escriba v");
315        status.push_str(env!("CARGO_PKG_VERSION"));
316        status.push(' ');
317        let mut status_buf = Buffer::new(&mut ctx.text.font_system, self.metrics);
318        status_buf.set_size(
319            &mut ctx.text.font_system,
320            Some(width),
321            Some(self.line_height * 2.0),
322        );
323        status_buf.set_text(
324            &mut ctx.text.font_system,
325            &status,
326            &Attrs::new().family(Family::Monospace),
327            Shaping::Advanced,
328        );
329        status_buf.shape_until_scroll(&mut ctx.text.font_system, false);
330
331        let status_color = chrome_glyph(palette.info);
332
333        let text_areas = [
334            TextArea {
335                buffer,
336                left: 8.0,
337                top: 8.0,
338                scale: 1.0,
339                bounds: TextBounds {
340                    left: 0,
341                    top: 0,
342                    right: ctx.width as i32,
343                    bottom: (height as i32).max(0),
344                },
345                default_color: fg,
346                custom_glyphs: &[],
347            },
348            TextArea {
349                buffer: &status_buf,
350                left: 8.0,
351                top: (ctx.height as f32 - self.line_height - 4.0).max(0.0),
352                scale: 1.0,
353                bounds: TextBounds {
354                    left: 0,
355                    top: (ctx.height as i32 - self.line_height as i32 - 4).max(0),
356                    right: ctx.width as i32,
357                    bottom: ctx.height as i32,
358                },
359                default_color: status_color,
360                custom_glyphs: &[],
361            },
362        ];
363
364        if let Err(e) = ctx.text.prepare(
365            &ctx.gpu.device,
366            &ctx.gpu.queue,
367            ctx.width,
368            ctx.height,
369            text_areas,
370        ) {
371            tracing::warn!(error = %e, "glyphon prepare failed");
372            return clear_frame(ctx);
373        }
374
375        // ── 3. Encode frame. ───────────────────────────────────────────
376        let mut encoder = ctx
377            .gpu
378            .device
379            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
380                label: Some("escriba frame"),
381            });
382        {
383            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
384                label: Some("escriba main pass"),
385                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
386                    view: ctx.surface_view,
387                    resolve_target: None,
388                    ops: wgpu::Operations {
389                        load: wgpu::LoadOp::Clear(ground_bg()),
390                        store: wgpu::StoreOp::Store,
391                    },
392                })],
393                depth_stencil_attachment: None,
394                timestamp_writes: None,
395                occlusion_query_set: None,
396            });
397            if let Err(e) = ctx.text.render(&mut pass) {
398                tracing::warn!(error = %e, "glyphon render failed");
399            }
400        }
401        ctx.gpu.queue.submit(std::iter::once(encoder.finish()));
402    }
403
404    fn resize(&mut self, width: u32, height: u32) {
405        if let Ok(mut s) = self.state.lock() {
406            // Monospace cell width estimate — glyphon advance for the
407            // Family::Monospace face is ≈ 0.6 × font_size. Used to derive a
408            // visible-column count so the horizontal-scroll window tracks the
409            // real window width (mirrors the visible-line derivation below).
410            let cell_w = (self.font_size * 0.6).max(1.0);
411            for w in &mut s.layout.windows {
412                w.rect.width = width;
413                w.rect.height = height;
414                // Rough visible-line count from height / line_height.
415                let lh = self.line_height.max(1.0);
416                w.viewport.visible_lines = ((height as f32 / lh).max(1.0) as u32).saturating_sub(1);
417                // Rough visible-column count from width / cell_width.
418                w.viewport.visible_columns = (width as f32 / cell_w).max(1.0) as u32;
419            }
420        }
421    }
422}
423
424/// The highlight registry escriba renders through: **tree-sitter grammars
425/// (hikari-ts) take precedence** for the languages they cover, and the zero-dep
426/// table backend fills every other language. So `.rs` gets real tree-sitter
427/// highlighting while `.py` / `.lisp` / `.json` / … get the batteries-included
428/// table lexer — and both flow through the same coverage-complete `HlClass`
429/// partition. Registration order is load-bearing: `Ecosystem::resolve` returns
430/// the first matching plugin, so tree-sitter (registered first) wins for its
431/// languages; the table backend is skipped for any language tree-sitter already
432/// covers (no duplicate). If the tree-sitter host fails to build, the table
433/// backend covers everything — never a panic, never an empty registry.
434///
435/// A third tier registers last: [`crate::langs::escriba_local`], the languages
436/// escriba serves that the fleet spine does not ship yet (today: blue). Last
437/// means an upstream hikari backend for the same language always wins, so a
438/// local table retires itself the day hikari grows one — no edit here, and no
439/// window where the two disagree.
440///
441/// Public because the registry IS escriba's language surface: a test that asks
442/// "does the editor know this language?" must be able to ask the same object
443/// the renderer holds, not a reconstruction of it.
444#[must_use]
445pub fn build_ecosystem() -> Ecosystem {
446    let mut eco = Ecosystem::new();
447    let mut covered: Vec<Language> = Vec::new();
448    if let Ok(host) = hikari_ts::TreeSitterHost::builtin() {
449        for p in host.plugins() {
450            covered.push(p.language());
451            eco.register(p);
452        }
453    }
454    for p in hikari_core::langs::builtins() {
455        if !covered.contains(&p.language()) {
456            covered.push(p.language());
457            eco.register(p);
458        }
459    }
460    for p in crate::langs::escriba_local() {
461        if !covered.contains(&p.language()) {
462            eco.register(p);
463        }
464    }
465    eco
466}
467
468/// Utility — clear the frame to Nord background. Used on error paths.
469fn clear_frame(ctx: &mut RenderContext<'_>) {
470    let mut encoder = ctx
471        .gpu
472        .device
473        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
474            label: Some("escriba clear"),
475        });
476    {
477        let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
478            label: Some("escriba clear pass"),
479            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
480                view: ctx.surface_view,
481                resolve_target: None,
482                ops: wgpu::Operations {
483                    load: wgpu::LoadOp::Clear(ground_bg()),
484                    store: wgpu::StoreOp::Store,
485                },
486            })],
487            depth_stencil_attachment: None,
488            timestamp_writes: None,
489            occlusion_query_set: None,
490        });
491    }
492    ctx.gpu.queue.submit(std::iter::once(encoder.finish()));
493}
494
495/// The editor ground as `wgpu::Color`, resolved from the fleet-prescribed
496/// theme's `background` role. Gamma-correct: the sRGB token is promoted
497/// through `ishou_tokens`' typed sRGB→linear path so it composites
498/// correctly on the linear-storage surface.
499fn ground_bg() -> wgpu::Color {
500    let c = ChromePalette::prescribed().background;
501    Srgb::from(c).to_linear().with_alpha(1.0).into()
502}
503
504/// ishou `Rgb` → glyphon `Color` (sRGB u8 RGBA, opaque). Theme-agnostic —
505/// was `vellum_glyph`, back when the paint path was hardwired to Vellum.
506/// Cut `range` wherever a match in `matches` starts or ends inside it.
507///
508/// Returns `(sub_range, is_match)` pieces that are contiguous, in order, and
509/// exactly cover `range` — the property `set_rich_text` depends on. `matches`
510/// are byte ranges into the SAME string `range` indexes.
511///
512/// Splitting the existing syntax partition (rather than building a second one)
513/// is what keeps the two colour sources composable: a match inside a string
514/// literal recolours only the matched bytes and the literal keeps its colour
515/// either side.
516fn split_on_matches(
517    range: std::ops::Range<usize>,
518    matches: &[(usize, usize)],
519) -> Vec<(std::ops::Range<usize>, bool)> {
520    let mut cuts: Vec<usize> = vec![range.start, range.end];
521    for &(a, b) in matches {
522        if a > range.start && a < range.end {
523            cuts.push(a);
524        }
525        if b > range.start && b < range.end {
526            cuts.push(b);
527        }
528    }
529    if cuts.len() == 2 {
530        // No boundary crosses this span — the common case, so avoid the
531        // sort/dedup entirely.
532        let hit = matches.iter().any(|&(a, b)| a <= range.start && b >= range.end);
533        return vec![(range, hit)];
534    }
535    cuts.sort_unstable();
536    cuts.dedup();
537    cuts.windows(2)
538        .map(|w| {
539            let (a, b) = (w[0], w[1]);
540            let hit = matches.iter().any(|&(ms, me)| ms <= a && me >= b);
541            (a..b, hit)
542        })
543        .collect()
544}
545
546fn chrome_glyph(c: Rgb) -> GlyphColor {
547    GlyphColor::rgba(c.r, c.g, c.b, 0xFF)
548}
549
550/// hikari `Rgb` (sRGB u8) → glyphon `Color` (opaque) — the syntax-span paint.
551fn hl_to_glyph(c: HlRgb) -> GlyphColor {
552    GlyphColor::rgba(c.r, c.g, c.b, 0xFF)
553}
554
555/// Mode indicator color — used by higher-layer rendering paths that want a
556/// glance-readable color. Named by ROLE so the hue follows the active theme:
557/// Normal info, Insert success, Visual accent, Command warning.
558#[must_use]
559pub fn mode_color(mode: Mode) -> Rgb {
560    let c = ChromePalette::prescribed();
561    match mode {
562        Mode::Insert => c.success,
563        Mode::Command => c.warning,
564        Mode::Visual | Mode::VisualLine => c.accent,
565        Mode::Normal => c.info,
566    }
567}
568
569/// The [`CursorShape`](escriba_core::CursorShape) the GPU backend should
570/// draw for `mode`. Derived from the single typed `Mode::cursor_shape`
571/// mapping shared with the TUI backend — so the GPU cursor (once it gains a
572/// dedicated glyph; today the buffer text carries the caret) renders the
573/// same shape the TUI does for any given mode. Exposed now so the shape is
574/// a typed value at the GPU layer, not a renderer-local literal later.
575#[must_use]
576pub fn cursor_shape(mode: Mode) -> escriba_core::CursorShape {
577    mode.cursor_shape()
578}
579
580/// Map an editor [`Mode`] to its fleet [`Signal`](ishou_tokens::Signal)
581/// from [`EscribaSignals`].
582///
583/// `VisualLine` shares `mode_visual` with `Visual` — the fleet signal set
584/// has one visual signal, matching how [`mode_color`] groups the two.
585#[must_use]
586pub fn mode_glyph(sig: &EscribaSignals, mode: Mode) -> &ishou_tokens::Signal {
587    match mode {
588        Mode::Normal => &sig.mode_normal,
589        Mode::Insert => &sig.mode_insert,
590        Mode::Visual | Mode::VisualLine => &sig.mode_visual,
591        Mode::Command => &sig.mode_command,
592    }
593}
594
595#[cfg(test)]
596mod tests {
597
598    // ── search-highlight overlay ──────────────────────────────────────
599    //
600    // set_rich_text requires a coverage-complete, non-overlapping, sorted
601    // partition. Splitting the syntax partition preserves that; these pin it,
602    // because a violation shows up as garbled text rather than a panic.
603
604    /// The invariant, asserted directly: pieces are contiguous, ordered, and
605    /// exactly cover the input range.
606    fn assert_partition(range: std::ops::Range<usize>, out: &[(std::ops::Range<usize>, bool)]) {
607        assert!(!out.is_empty(), "a range must yield at least one piece");
608        assert_eq!(out[0].0.start, range.start, "starts at the range start");
609        assert_eq!(out[out.len() - 1].0.end, range.end, "ends at the range end");
610        for w in out.windows(2) {
611            assert_eq!(w[0].0.end, w[1].0.start, "pieces are contiguous, no gap or overlap");
612        }
613    }
614
615    #[test]
616    fn a_span_with_no_match_is_returned_whole() {
617        let out = split_on_matches(0..10, &[]);
618        assert_eq!(out.len(), 1, "no needless splitting");
619        assert!(!out[0].1);
620        assert_partition(0..10, &out);
621    }
622
623    #[test]
624    fn a_match_covering_the_whole_span_marks_it_without_splitting() {
625        let out = split_on_matches(4..8, &[(0, 20)]);
626        assert_eq!(out.len(), 1);
627        assert!(out[0].1, "fully covered span is a match");
628        assert_partition(4..8, &out);
629    }
630
631    #[test]
632    fn a_match_starting_mid_span_splits_it_in_two() {
633        // Syntax span 0..10, match 5..10 -> [0..5 plain][5..10 match]
634        let out = split_on_matches(0..10, &[(5, 10)]);
635        assert_eq!(out.len(), 2);
636        assert_eq!(out[0], (0..5, false));
637        assert_eq!(out[1], (5..10, true));
638        assert_partition(0..10, &out);
639    }
640
641    #[test]
642    fn a_match_inside_a_span_splits_it_in_three() {
643        // This is the case that matters: a match inside a string literal must
644        // recolour only the matched bytes, leaving the literal coloured
645        // either side.
646        let out = split_on_matches(0..10, &[(3, 6)]);
647        assert_eq!(out.len(), 3);
648        assert_eq!(out[0], (0..3, false));
649        assert_eq!(out[1], (3..6, true));
650        assert_eq!(out[2], (6..10, false));
651        assert_partition(0..10, &out);
652    }
653
654    #[test]
655    fn two_matches_in_one_span_both_split() {
656        let out = split_on_matches(0..20, &[(2, 4), (10, 12)]);
657        assert_partition(0..20, &out);
658        let hits: Vec<_> = out.iter().filter(|(_, m)| *m).map(|(r, _)| r.clone()).collect();
659        assert_eq!(hits, vec![2..4, 10..12]);
660    }
661
662    #[test]
663    fn a_match_entirely_outside_the_span_changes_nothing() {
664        let out = split_on_matches(10..20, &[(0, 5)]);
665        assert_eq!(out.len(), 1);
666        assert!(!out[0].1);
667        assert_partition(10..20, &out);
668    }
669
670    #[test]
671    fn a_match_touching_the_span_edge_does_not_create_an_empty_piece() {
672        // Boundary exactly at the edge must not emit a zero-width run.
673        for m in [(0usize, 10usize), (10, 20)] {
674            let out = split_on_matches(10..20, &[m]);
675            assert_partition(10..20, &out);
676            assert!(out.iter().all(|(r, _)| r.start < r.end), "no empty piece for {m:?}");
677        }
678    }
679
680    #[test]
681    fn adjacent_matches_do_not_produce_duplicate_cuts() {
682        // Two matches meeting at 5 must yield one cut there, not two.
683        let out = split_on_matches(0..10, &[(0, 5), (5, 10)]);
684        assert_partition(0..10, &out);
685        assert!(out.iter().all(|(r, _)| r.start < r.end));
686        assert!(out.iter().all(|(_, m)| *m), "both halves are matches");
687    }
688    use super::*;
689    use escriba_buffer::BufferSet;
690
691    #[test]
692    fn ground_is_the_prescribed_theme_promoted_to_linear() {
693        let bg = ground_bg();
694        // Was pinned to Vellum's warm parchment (night0 #16140E, r >= g >= b).
695        // The prescribed theme is now Nord, whose ground is COOL (b >= r), so
696        // the old warmth assertion was theme-specific and had to go. What is
697        // actually invariant — and worth asserting — is that the ground is a
698        // dark, opaque, gamma-correct promotion of the theme's own
699        // background role.
700        let want = Srgb::from(ChromePalette::prescribed().background)
701            .to_linear()
702            .with_alpha(1.0);
703        let want: wgpu::Color = want.into();
704        assert!((bg.r - want.r).abs() < 1e-6, "r {} != {}", bg.r, want.r);
705        assert!((bg.g - want.g).abs() < 1e-6, "g {} != {}", bg.g, want.g);
706        assert!((bg.b - want.b).abs() < 1e-6, "b {} != {}", bg.b, want.b);
707        assert_eq!(bg.a, 1.0);
708        // Dark ground: an editor background must stay well below mid-grey in
709        // linear space whatever the theme.
710        assert!(bg.r < 0.1 && bg.g < 0.1 && bg.b < 0.1, "ground is not dark: {bg:?}");
711    }
712
713    #[test]
714    fn renderer_construction_is_cheap() {
715        let mut bufs = BufferSet::new();
716        let id = bufs.scratch("hello\n");
717        let state = Arc::new(Mutex::new(EditorState::new_with_buffer(bufs, id)));
718        let _r = GpuRenderer::new(state);
719    }
720
721    /// Phase 4: the render Ecosystem serves `.rs` from the **tree-sitter**
722    /// backend (hikari-ts) and other languages from the table backend — both a
723    /// coverage-complete `HlClass` partition. Proves real tree-sitter
724    /// highlighting is wired into the live render path (not just the table lexer).
725    #[test]
726    fn ecosystem_uses_tree_sitter_for_rust_and_table_for_the_rest() {
727        use hikari_core::{HlClass, Language};
728        let eco = build_ecosystem();
729        // .rs resolves to a grammar and produces real (non-Plain) classification.
730        assert_eq!(eco.resolve("src/main.rs"), Language("rust"));
731        let rs = eco
732            .highlighter_for_path("src/main.rs")
733            .highlight("fn main() { let x = 42; }");
734        assert!(
735            rs.iter().any(|s| s.class != HlClass::Plain),
736            "rust must be really highlighted (tree-sitter or table)",
737        );
738        // Python is also served (tree-sitter, once hikari-ts ships that grammar;
739        // the table backend covers it otherwise) — either way it classifies.
740        assert_eq!(eco.resolve("app.py"), Language("python"));
741        // A tree-sitter-uncovered language still resolves via the table backend.
742        assert_eq!(eco.resolve("init.lisp"), Language("lisp"));
743        // An unknown extension is still total (plain text, never a panic).
744        assert_eq!(eco.resolve("notes.xyz"), hikari_core::PLAIN_TEXT);
745    }
746
747    #[test]
748    fn mode_colors_differ_by_mode() {
749        let n = mode_color(Mode::Normal);
750        let i = mode_color(Mode::Insert);
751        let v = mode_color(Mode::Visual);
752        assert_ne!((n.r, n.g, n.b), (i.r, i.g, i.b));
753        assert_ne!((n.r, n.g, n.b), (v.r, v.g, v.b));
754    }
755
756    #[test]
757    fn cursor_shape_tracks_mode() {
758        use escriba_core::CursorShape;
759        assert_eq!(cursor_shape(Mode::Normal), CursorShape::Block);
760        assert_eq!(cursor_shape(Mode::Command), CursorShape::Block);
761        assert_eq!(cursor_shape(Mode::Insert), CursorShape::Bar);
762        assert_eq!(cursor_shape(Mode::Visual), CursorShape::Underline);
763        assert_eq!(cursor_shape(Mode::VisualLine), CursorShape::Underline);
764    }
765
766    /// Mode pills map to ROLES, not to one theme's hexes. This test used to
767    /// pin the four Vellum values (`#94BBB8` …), which is precisely why it
768    /// went red the moment the fleet theme moved — a test asserting a
769    /// theme's spelling has to be rewritten on every theme change, and is
770    /// no evidence the mapping is right. Asserting role identity instead
771    /// survives the move AND still catches a mis-wired pill.
772    #[test]
773    fn mode_colors_are_role_pills() {
774        let c = ChromePalette::prescribed();
775        assert_eq!(mode_color(Mode::Normal).hex(), c.info.hex(), "Normal = info");
776        assert_eq!(mode_color(Mode::Insert).hex(), c.success.hex(), "Insert = success");
777        assert_eq!(mode_color(Mode::Visual).hex(), c.accent.hex(), "Visual = accent");
778        assert_eq!(mode_color(Mode::Command).hex(), c.warning.hex(), "Command = warning");
779
780        // The four pills must be mutually distinct, or the mode is not
781        // glance-readable regardless of which theme is active.
782        let mut seen = std::collections::BTreeSet::new();
783        for m in [Mode::Normal, Mode::Insert, Mode::Visual, Mode::Command] {
784            assert!(seen.insert(mode_color(m).hex()), "{m:?} duplicates another pill");
785        }
786    }
787
788    /// Forcing function: the status-line mode glyphs are sourced from the
789    /// fleet `EscribaSignals` vocabulary, not hand-picked literals. Pins
790    /// the geometric `Glyph`-mode marks so drift in either escriba or
791    /// ishou surfaces here.
792    #[test]
793    fn mode_glyphs_are_fleet_signals() {
794        let sig = EscribaSignals::prescribed();
795        assert_eq!(mode_glyph(&sig, Mode::Normal).render(SignalMode::Glyph), "◆");
796        assert_eq!(mode_glyph(&sig, Mode::Insert).render(SignalMode::Glyph), "▸");
797        assert_eq!(mode_glyph(&sig, Mode::Visual).render(SignalMode::Glyph), "▮");
798        assert_eq!(
799            mode_glyph(&sig, Mode::VisualLine).render(SignalMode::Glyph),
800            "▮"
801        );
802        assert_eq!(
803            mode_glyph(&sig, Mode::Command).render(SignalMode::Glyph),
804            ":"
805        );
806    }
807
808    /// Fleet convergence guard: escriba's GPU chrome paints whatever
809    /// `ChromePalette::prescribed()` resolves, which is
810    /// `FleetTheme::prescribed_default()` BY CONSTRUCTION — so this Guard
811    /// cannot be satisfied by a stale hand-written constant.
812    ///
813    /// It previously hardcoded `FleetTheme::Vellum` to match a paint path
814    /// hardwired to `VellumPalette::vellum()`. When the fleet moved its
815    /// prescribed theme to PlemeDark (Nord) this went RED — correctly, since
816    /// the GPU backend really was painting the wrong theme while the TUI
817    /// face and the rest of the fleet (mado, tear, frostmourne, …) moved on.
818    /// Smallest real editor state — a scratch buffer. The theming tests
819    /// care about the palette, not the buffer, but GpuRenderer owns state.
820    fn test_renderer() -> GpuRenderer {
821        let mut bufs = escriba_buffer::BufferSet::new();
822        let id = bufs.scratch("");
823        GpuRenderer::new(Arc::new(Mutex::new(EditorState::new_with_buffer(bufs, id))))
824    }
825
826    #[test]
827    fn default_theme_is_the_fleet_prescribed_nord() {
828        // Nord is the default because the FLEET says so — asserted against
829        // FleetTheme::prescribed_default(), never a hand-written "nord",
830        // so a fleet re-point cannot leave escriba silently behind.
831        let r = test_renderer();
832        let want = ChromePalette::for_theme(ishou_tokens::FleetTheme::prescribed_default());
833        assert_eq!(r.chrome().hex_tuple(), want.hex_tuple());
834    }
835
836    #[test]
837    fn set_theme_actually_changes_what_is_painted() {
838        // The wiring this exists to prove: before it, every paint site
839        // called ChromePalette::prescribed() directly, so (deftheme :preset)
840        // resolved to a real FleetTheme that NOTHING consumed. If set_theme
841        // ever stops reaching the paint path, this fails.
842        let mut r = test_renderer();
843        let before = r.chrome().hex_tuple();
844        r.set_theme(ishou_tokens::FleetTheme::Vellum);
845        let after = r.chrome().hex_tuple();
846        assert_ne!(
847            before, after,
848            "switching to Vellum must change the painted palette"
849        );
850        assert_eq!(
851            after,
852            ChromePalette::for_theme(ishou_tokens::FleetTheme::Vellum).hex_tuple()
853        );
854        // And it is reversible — a theme is a value, not a one-way latch.
855        r.set_theme(ishou_tokens::FleetTheme::prescribed_default());
856        assert_eq!(r.chrome().hex_tuple(), before);
857    }
858
859    #[test]
860    fn escriba_gpu_chrome_converges_with_fleet() {
861        use ishou_tokens::{FleetTheme, convergence::Guard};
862        let chrome_theme = FleetTheme::prescribed_default();
863        Guard::for_app("escriba-render").expect_theme(chrome_theme).run();
864    }
865}