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