Skip to main content

escriba_tui/
render.rs

1//! Ratatui rendering — draws buffer pane + status line each frame.
2//!
3//! Chrome colors are the **Vellum** fleet theme (warm aged-paper
4//! Nord-matte) — every value is a BORN `ishou_tokens::VellumPalette`
5//! token, so the TUI chrome matches the rest of the fleet (mado, tear,
6//! frostmourne, …) and the GPU backend.
7
8use escriba_core::CursorShape;
9use escriba_runtime::EditorState;
10use escriba_ui::chrome::ChromePalette;
11use ishou_tokens::{EscribaSignals, SignalMode};
12use ratatui::Frame;
13use ratatui::layout::{Constraint, Direction, Layout as RLayout};
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::{Block, Borders, Paragraph};
17
18/// ishou `Rgb` → ratatui `Color::Rgb`. The single conversion point so
19/// every chrome color flows from the BORN Vellum tokens.
20/// Theme-agnostic `ishou` color → ratatui color. (Was `vellum()`, back when
21/// the paint path was hardwired to one theme.)
22fn rgb(c: ishou_tokens::Rgb) -> Color {
23    Color::Rgb(c.r, c.g, c.b)
24}
25
26/// Draw one frame. Call from within `terminal.draw(|f| draw_frame(f, state))`.
27pub fn draw_frame(f: &mut Frame<'_>, state: &EditorState) {
28    let area = f.area();
29    let chunks = RLayout::default()
30        .direction(Direction::Vertical)
31        .constraints([Constraint::Min(3), Constraint::Length(1)])
32        .split(area);
33
34    draw_buffer(f, chunks[0], state);
35    draw_status_line(f, chunks[1], state);
36}
37
38fn draw_buffer(f: &mut Frame<'_>, area: ratatui::layout::Rect, state: &EditorState) {
39    let Some(buf) = state.buffers.get(state.active) else {
40        f.render_widget(Paragraph::new("<no buffer>").style(error_style()), area);
41        return;
42    };
43
44    let win = state.layout.active_window();
45    let top = win.map_or(0, |w| w.viewport.top_line);
46    let left = win.map_or(0, |w| w.viewport.left_column);
47    // Visible width minus the gutter ("{:>4} │ " = 7 columns).
48    let vis_cols = win.map_or(usize::MAX, |w| w.viewport.visible_columns as usize);
49    let visible = area.height.saturating_sub(2).max(1);
50    let cursor = state.cursor();
51    // The cursor's on-screen shape is derived from the active mode through
52    // the one typed `Mode::cursor_shape` function — block in Normal/Command,
53    // bar in Insert, underline in Visual. Both backends read it from there,
54    // so the shapes can't drift apart.
55    let shape = state.modal.mode().cursor_shape();
56
57    let mut lines: Vec<Line<'static>> = Vec::with_capacity(visible as usize);
58    for row in 0..visible as u32 {
59        let ln = top + row;
60        if ln >= buf.line_count() {
61            break;
62        }
63        let Some(line_str) = buf.line(ln) else {
64            continue;
65        };
66        let text = line_str
67            .trim_end_matches('\n')
68            .trim_end_matches('\r')
69            .to_string();
70        // Search matches are DOCUMENT char offsets; the renderer paints
71        // COLUMNS. Translate once per line via the line's own start offset,
72        // so no offset arithmetic leaks into the span builder.
73        let line_start = buf.position_to_char(escriba_core::Position::new(ln, 0)).unwrap_or(0);
74        let line_len = text.chars().count();
75        let hl: Vec<(usize, usize)> = state
76            .search
77            .highlights()
78            .iter()
79            .filter_map(|m| {
80                // Clip the match to this line; a multi-line match paints its
81                // overlapping part on each line it crosses.
82                let s = m.start.saturating_sub(line_start);
83                let e = m.end.saturating_sub(line_start);
84                (m.end > line_start && m.start < line_start + line_len + 1)
85                    .then(|| (s.min(line_len), e.min(line_len)))
86            })
87            .filter(|(s, e)| e > s)
88            .collect();
89        lines.push(line_with_gutter(
90            ln,
91            &text,
92            cursor,
93            left as usize,
94            vis_cols,
95            shape,
96            &hl,
97        ));
98    }
99
100    let block = Block::default()
101        .borders(Borders::NONE)
102        .style(buffer_style());
103    f.render_widget(Paragraph::new(lines).block(block), area);
104}
105
106/// Render one line with a gutter, sliced horizontally to the visible
107/// column window `[left, left + vis_cols)`. Slicing is char-based (not
108/// byte-based) so multibyte text stays aligned, and the cursor's on-screen
109/// column is computed relative to `left` so the cursor glyph tracks the
110/// horizontal scroll.
111fn line_with_gutter(
112    ln: u32,
113    text: &str,
114    cursor: escriba_core::Position,
115    left: usize,
116    vis_cols: usize,
117    shape: CursorShape,
118    highlights: &[(usize, usize)],
119) -> Line<'static> {
120    let gutter = format!("{:>4} │ ", ln + 1);
121    let mut spans = vec![Span::styled(gutter, muted_style())];
122
123    let chars: Vec<char> = text.chars().collect();
124    // The slice of characters actually visible in this window.
125    let visible: Vec<char> = chars.iter().copied().skip(left).take(vis_cols).collect();
126
127    // One style slot per visible cell. Painting cell-by-cell and coalescing
128    // afterwards is what lets the cursor and any number of search matches
129    // overlap on the same line — the previous before/cursor/after split could
130    // only ever express ONE styled region, so highlights had nowhere to go.
131    let mut cell_styles: Vec<Option<Style>> = vec![None; visible.len()];
132    for &(hs, he) in highlights {
133        for col in hs..he {
134            if col >= left {
135                if let Some(slot) = cell_styles.get_mut(col - left) {
136                    *slot = Some(search_match_style());
137                }
138            }
139        }
140    }
141
142    // The cursor wins over a highlight on its own cell — you must always be
143    // able to see where you are, even sitting on a match.
144    let cursor_here =
145        (ln == cursor.line && cursor.column as usize >= left).then(|| cursor.column as usize - left);
146
147    if let Some(rel) = cursor_here {
148        if rel >= visible.len() {
149            push_runs(&mut spans, &visible, &cell_styles);
150            spans.extend(cursor_spans(' ', shape));
151            return Line::from(spans);
152        }
153        push_runs(&mut spans, &visible[..rel], &cell_styles[..rel]);
154        spans.extend(cursor_spans(visible[rel], shape));
155        push_runs(&mut spans, &visible[rel + 1..], &cell_styles[rel + 1..]);
156    } else {
157        push_runs(&mut spans, &visible, &cell_styles);
158    }
159
160    Line::from(spans)
161}
162
163/// Emit `chars` as the fewest spans that preserve `styles`, merging adjacent
164/// cells that share a style. Without the merge a 200-column line would emit
165/// 200 single-char spans every frame.
166fn push_runs(spans: &mut Vec<Span<'static>>, chars: &[char], styles: &[Option<Style>]) {
167    debug_assert_eq!(chars.len(), styles.len(), "one style slot per cell");
168    let mut i = 0;
169    while i < chars.len() {
170        let style = styles.get(i).copied().flatten();
171        let mut j = i + 1;
172        while j < chars.len() && styles.get(j).copied().flatten() == style {
173            j += 1;
174        }
175        let run: String = chars[i..j].iter().collect();
176        spans.push(match style {
177            Some(st) => Span::styled(run, st),
178            None => Span::raw(run),
179        });
180        i = j;
181    }
182}
183
184/// Render the cell under the cursor in its per-mode [`CursorShape`].
185///
186/// - [`CursorShape::Block`]: fill the cell (dark glyph on the cursor color)
187///   — the Normal/Command "you are here" indicator.
188/// - [`CursorShape::Bar`]: a thin vertical bar drawn BEFORE the glyph
189///   (Insert mode's between-glyphs caret), the glyph itself left plain.
190/// - [`CursorShape::Underline`]: the glyph with an underline modifier
191///   (Visual mode), so the highlighted selection stays readable.
192fn cursor_spans(under: char, shape: CursorShape) -> Vec<Span<'static>> {
193    match shape {
194        CursorShape::Block => vec![Span::styled(under.to_string(), cursor_block_style())],
195        CursorShape::Bar => vec![
196            Span::styled("▏".to_string(), cursor_bar_style()),
197            Span::raw(under.to_string()),
198        ],
199        CursorShape::Underline => vec![Span::styled(under.to_string(), cursor_underline_style())],
200    }
201}
202
203fn draw_status_line(f: &mut Frame<'_>, area: ratatui::layout::Rect, state: &EditorState) {
204    let mode = state.modal.mode().as_str();
205    let pos = format!("{}:{}", state.cursor().line + 1, state.cursor().column + 1);
206    let path = state
207        .buffers
208        .get(state.active)
209        .and_then(|b| b.path.clone())
210        .map_or("scratch".to_string(), |p| p.display().to_string());
211    let modified = state.buffers.get(state.active).is_some_and(|b| b.modified);
212    // Status glyphs are the BORN fleet vocabulary (`ishou_tokens::EscribaSignals`),
213    // not hand-picked literals. Single-width `Glyph` mode keeps the
214    // status-line column alignment-safe.
215    let sig = EscribaSignals::prescribed();
216    let modified_indicator = if modified {
217        format!(" {}", sig.modified.render(SignalMode::Glyph))
218    } else {
219        String::new()
220    };
221
222    // Mode pill = fleet mode glyph + escriba's canonical uppercase label.
223    let mode_glyph = mode_signal(&sig, state.modal.mode()).render(SignalMode::Glyph);
224    let mode_span = Span::styled(
225        format!(" {mode_glyph} {mode} "),
226        mode_style_for(state.modal.mode()),
227    );
228    let path_span = Span::styled(format!(" {path}{modified_indicator} "), status_style());
229    let minibuffer = if state.modal.mode() == escriba_core::Mode::Command {
230        {
231        // Command mode now hosts BOTH the ex-line and the search prompt (vim's
232        // cmdline does the same). Showing a hardcoded ':' made a `/foo` search
233        // render as `:foo` — the prefix must report which prompt is actually
234        // open, and `search.prompt()` is the same typed discriminator the
235        // runtime routes <CR> with, so the two cannot disagree.
236        let prefix = match state.search.prompt().map(|p| p.direction) {
237            Some(escriba_search::Direction::Forward) => '/',
238            Some(escriba_search::Direction::Backward) => '?',
239            None => ':',
240        };
241        let mut line = String::from(" ");
242        line.push(prefix);
243        line.push_str(state.modal.minibuffer());
244        Span::styled(line, cmd_style())
245    }
246    } else {
247        Span::raw("")
248    };
249    let pos_span = Span::styled(format!(" {pos} "), status_style());
250
251    // Layout: [mode] [path+modified] … (flex) … [minibuffer] [pos]
252    let available = usize::from(area.width);
253    let left = format!("{}{}", mode_span.content, path_span.content,);
254    let right = format!("{}{}", minibuffer.content, pos_span.content);
255    let pad = available.saturating_sub(left.chars().count() + right.chars().count());
256
257    let line = Line::from(vec![
258        mode_span,
259        path_span,
260        Span::raw(" ".repeat(pad)),
261        minibuffer,
262        pos_span,
263    ]);
264    f.render_widget(Paragraph::new(line).style(status_style()), area);
265}
266
267// ─── Styles — Vellum (warm aged-paper Nord-matte) ───────────────────────
268//
269// Every chrome color resolves through `escriba_ui::chrome::ChromePalette`
270// — the one theme seam, shared with the GPU backend so the two faces cannot
271// drift apart. Colors are named by ROLE (text / surface / cursor / error),
272// never by a theme's own token spelling, which is what lets the theme change
273// without touching a single call site here.
274//
275// `ChromePalette::prescribed()` is cheap (plain struct construction from
276// ishou role bindings); the per-call cost is negligible at the
277// once-per-frame cadence these helpers run at.
278//
279// NOTE: these read the FLEET-PRESCRIBED theme, not a per-buffer
280// `(deftheme :preset …)`. Threading the operator's chosen theme down to the
281// paint path is the remaining half of the theming work — the seam now exists
282// (`ChromePalette::for_theme`), but nothing calls it with a config value yet.
283
284fn buffer_style() -> Style {
285    let c = ChromePalette::prescribed();
286    Style::default().fg(rgb(c.text)).bg(rgb(c.background))
287}
288
289fn muted_style() -> Style {
290    let c = ChromePalette::prescribed();
291    Style::default().fg(rgb(c.text_dim)) // comment / gutter
292}
293
294/// Block cursor (Normal / Command) — dark glyph filled onto the cursor
295/// color, the "you are here" cell.
296fn cursor_block_style() -> Style {
297    let c = ChromePalette::prescribed();
298    Style::default()
299        .fg(rgb(c.background)) // ground-colored text on the cursor
300        .bg(rgb(c.cursor))
301        .add_modifier(Modifier::BOLD)
302}
303
304/// Bar cursor (Insert) — the thin vertical caret drawn between glyphs,
305/// colored in the cursor accent.
306fn cursor_bar_style() -> Style {
307    let c = ChromePalette::prescribed();
308    Style::default().fg(rgb(c.cursor)).add_modifier(Modifier::BOLD)
309}
310
311/// Underline cursor (Visual) — the glyph kept legible with an underline in
312/// the cursor accent.
313/// Style for a search match under `hlsearch`.
314///
315/// Reversed against the `warning` role rather than a literal colour: it reads
316/// as "look here" without colliding with `cursor` (which must stay
317/// distinguishable when the cursor sits ON a match) or with `error`. Sourced
318/// from ChromePalette so it follows the fleet theme like every other style
319/// here — a hardcoded hex would be the one span that ignores the theme.
320fn search_match_style() -> Style {
321    let c = ChromePalette::prescribed();
322    Style::default().fg(rgb(c.background)).bg(rgb(c.warning))
323}
324
325fn cursor_underline_style() -> Style {
326    let c = ChromePalette::prescribed();
327    Style::default()
328        .fg(rgb(c.cursor))
329        .add_modifier(Modifier::UNDERLINED)
330        .add_modifier(Modifier::BOLD)
331}
332
333fn status_style() -> Style {
334    let c = ChromePalette::prescribed();
335    // Was a raw `Color::Rgb(0xCD, 0xC7, 0xB6)` literal ("statusline_fg,
336    // Vellum extra") — the one genuinely hardcoded color in this file, and
337    // dead weight the moment the theme moved. It is now the `text` role.
338    Style::default().fg(rgb(c.text)).bg(rgb(c.surface))
339}
340
341fn cmd_style() -> Style {
342    let c = ChromePalette::prescribed();
343    Style::default().fg(rgb(c.warning)).bg(rgb(c.surface)).add_modifier(Modifier::BOLD)
344}
345
346fn error_style() -> Style {
347    let c = ChromePalette::prescribed();
348    Style::default().fg(rgb(c.error)).bg(rgb(c.background))
349}
350
351/// Map an editor [`Mode`](escriba_core::Mode) to its fleet
352/// [`Signal`](ishou_tokens::Signal) from [`EscribaSignals`].
353///
354/// `VisualLine` shares `mode_visual` with `Visual` — the fleet signal
355/// set has one visual signal, matching how [`mode_style_for`] groups the
356/// two under one pill color.
357fn mode_signal(sig: &EscribaSignals, mode: escriba_core::Mode) -> &ishou_tokens::Signal {
358    match mode {
359        escriba_core::Mode::Normal => &sig.mode_normal,
360        escriba_core::Mode::Insert => &sig.mode_insert,
361        escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => &sig.mode_visual,
362        escriba_core::Mode::Command => &sig.mode_command,
363    }
364}
365
366fn mode_style_for(mode: escriba_core::Mode) -> Style {
367    let c = ChromePalette::prescribed();
368    // Mode pills — ground-colored text on a role-colored field:
369    // Normal info, Insert success, Visual accent, Command warning. Naming
370    // the ROLE rather than the hue is what keeps these correct across
371    // themes: on Nord `info` is frost blue, on Vellum it was ice cyan, and
372    // neither call site has to know.
373    let bg = match mode {
374        escriba_core::Mode::Normal => c.info,
375        escriba_core::Mode::Insert => c.success,
376        escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => c.accent,
377        escriba_core::Mode::Command => c.warning,
378    };
379    Style::default().fg(rgb(c.background)).bg(rgb(bg)).add_modifier(Modifier::BOLD)
380}
381
382#[cfg(test)]
383mod tests {
384
385    // ── search highlight rendering ────────────────────────────────────
386
387    fn styles_of(spans: &[Span<'static>]) -> Vec<(String, bool)> {
388        // (text, is-highlighted) — comparing against the exact Style would
389        // pin the palette, which is a theming concern, not a layout one.
390        spans
391            .iter()
392            .map(|sp| (sp.content.to_string(), sp.style.bg == search_match_style().bg))
393            .collect()
394    }
395
396    #[test]
397    fn push_runs_merges_adjacent_cells_of_equal_style() {
398        // A 200-column line must not emit 200 spans per frame.
399        let chars: Vec<char> = "aaaabbbb".chars().collect();
400        let mut styles = vec![None; 8];
401        for slot in styles.iter_mut().take(4) {
402            *slot = Some(search_match_style());
403        }
404        let mut spans = vec![];
405        push_runs(&mut spans, &chars, &styles);
406        assert_eq!(spans.len(), 2, "one span per run, not per char");
407        assert_eq!(spans[0].content, "aaaa");
408        assert_eq!(spans[1].content, "bbbb");
409    }
410
411    #[test]
412    fn push_runs_on_empty_input_emits_nothing() {
413        let mut spans = vec![];
414        push_runs(&mut spans, &[], &[]);
415        assert!(spans.is_empty());
416    }
417
418    #[test]
419    fn a_match_is_painted_and_the_rest_is_not() {
420        // "hello world", match on "world" (cols 6..11), cursor elsewhere.
421        let line = line_with_gutter(
422            0,
423            "hello world",
424            escriba_core::Position::new(9, 0), // cursor on another line
425            0,
426            80,
427            CursorShape::Block,
428            &[(6, 11)],
429        );
430        let painted: Vec<String> = styles_of(&line.spans)
431            .into_iter()
432            .filter(|(_, hl)| *hl)
433            .map(|(t, _)| t)
434            .collect();
435        assert_eq!(painted, vec!["world".to_string()], "exactly the match is lit");
436    }
437
438    #[test]
439    fn two_matches_on_one_line_are_both_painted() {
440        // The old before/cursor/after split could express only ONE styled
441        // region — this is the case it structurally could not render.
442        let line = line_with_gutter(
443            0,
444            "foo bar foo",
445            escriba_core::Position::new(9, 0),
446            0,
447            80,
448            CursorShape::Block,
449            &[(0, 3), (8, 11)],
450        );
451        let painted: Vec<String> = styles_of(&line.spans)
452            .into_iter()
453            .filter(|(_, hl)| *hl)
454            .map(|(t, _)| t)
455            .collect();
456        assert_eq!(painted, vec!["foo".to_string(), "foo".to_string()]);
457    }
458
459    #[test]
460    fn the_cursor_stays_visible_when_sitting_on_a_match() {
461        // A highlight must never swallow the cursor cell, or you lose your
462        // place the moment you land on a match — which is always, after `n`.
463        let line = line_with_gutter(
464            0,
465            "foo bar",
466            escriba_core::Position::new(0, 1),
467            0,
468            80,
469            CursorShape::Block,
470            &[(0, 3)],
471        );
472        let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
473        assert!(texts.contains(&"o".to_string()), "cursor cell rendered alone: {texts:?}");
474    }
475
476    #[test]
477    fn highlights_respect_horizontal_scroll() {
478        // Scrolled right by 4: the match at cols 6..11 must shift left by 4.
479        let line = line_with_gutter(
480            0,
481            "hello world",
482            escriba_core::Position::new(9, 0),
483            4,
484            80,
485            CursorShape::Block,
486            &[(6, 11)],
487        );
488        let painted: Vec<String> = styles_of(&line.spans)
489            .into_iter()
490            .filter(|(_, hl)| *hl)
491            .map(|(t, _)| t)
492            .collect();
493        assert_eq!(painted, vec!["world".to_string()], "still exactly the match");
494    }
495
496    #[test]
497    fn no_highlights_renders_a_plain_line() {
498        let line = line_with_gutter(
499            0,
500            "hello world",
501            escriba_core::Position::new(9, 0),
502            0,
503            80,
504            CursorShape::Block,
505            &[],
506        );
507        assert!(styles_of(&line.spans).iter().all(|(_, hl)| !hl), "nothing lit");
508    }
509    use super::*;
510    use escriba_core::Mode;
511
512    /// Forcing function: the status-line mode glyphs are sourced from the
513    /// fleet `EscribaSignals` vocabulary, not hand-picked literals.
514    #[test]
515    fn mode_glyphs_are_fleet_signals() {
516        let sig = EscribaSignals::prescribed();
517        assert_eq!(mode_signal(&sig, Mode::Normal).render(SignalMode::Glyph), "◆");
518        assert_eq!(mode_signal(&sig, Mode::Insert).render(SignalMode::Glyph), "▸");
519        assert_eq!(mode_signal(&sig, Mode::Visual).render(SignalMode::Glyph), "▮");
520        assert_eq!(
521            mode_signal(&sig, Mode::VisualLine).render(SignalMode::Glyph),
522            "▮"
523        );
524        assert_eq!(
525            mode_signal(&sig, Mode::Command).render(SignalMode::Glyph),
526            ":"
527        );
528    }
529
530    /// The modified indicator is the fleet `modified` glyph (`●`), not a
531    /// hand-picked literal.
532    #[test]
533    fn modified_indicator_is_fleet_signal() {
534        let sig = EscribaSignals::prescribed();
535        assert_eq!(sig.modified.render(SignalMode::Glyph), "●");
536    }
537
538    /// The cursor is rendered in its per-mode shape: a block fills the
539    /// cell (Normal), a bar precedes the glyph (Insert), an underline marks
540    /// the glyph (Visual). The shape is selected by `Mode::cursor_shape`.
541    #[test]
542    fn cursor_spans_render_per_mode_shape() {
543        // Block: a single span styled with the cursor BG (block fill).
544        let block = cursor_spans('a', CursorShape::Block);
545        assert_eq!(block.len(), 1);
546        assert_eq!(block[0].content, "a");
547        // The cursor ROLE, not a theme's own token — this assertion used to
548        // name `VellumPalette::vellum().green_bright`, which pinned the test
549        // to one theme and would have had to change on every theme move.
550        assert_eq!(block[0].style.bg, Some(rgb(ChromePalette::prescribed().cursor)));
551
552        // Bar: a thin caret span BEFORE the (unstyled) glyph.
553        let bar = cursor_spans('a', CursorShape::Bar);
554        assert_eq!(bar.len(), 2);
555        assert_eq!(bar[0].content, "▏");
556        assert_eq!(bar[1].content, "a");
557        assert_eq!(bar[1].style.bg, None, "bar leaves the glyph cell unfilled");
558
559        // Underline: one glyph span carrying the UNDERLINED modifier.
560        let under = cursor_spans('a', CursorShape::Underline);
561        assert_eq!(under.len(), 1);
562        assert!(under[0].style.add_modifier.contains(Modifier::UNDERLINED));
563    }
564
565    /// End-to-end: the shape the buffer pane uses is derived from the live
566    /// modal mode through the one typed `Mode::cursor_shape` mapping.
567    #[test]
568    fn buffer_shape_follows_modal_mode() {
569        use escriba_core::Mode;
570        assert_eq!(Mode::Normal.cursor_shape(), CursorShape::Block);
571        assert_eq!(Mode::Insert.cursor_shape(), CursorShape::Bar);
572        assert_eq!(Mode::Visual.cursor_shape(), CursorShape::Underline);
573    }
574
575    /// Fleet convergence guard: escriba's TUI chrome paints whatever
576    /// `ChromePalette::prescribed()` resolves, which is
577    /// `FleetTheme::prescribed_default()` BY CONSTRUCTION — so this Guard
578    /// can no longer be satisfied by a stale hand-written constant.
579    ///
580    /// It previously hardcoded `FleetTheme::Vellum` here to match a paint
581    /// path hardwired to `VellumPalette::vellum()`. When the fleet moved its
582    /// prescribed theme to PlemeDark (Nord), that made the test RED —
583    /// correctly: escriba really was painting the wrong theme. Asserting the
584    /// resolved value instead of a literal is what stops that class of drift
585    /// from needing a human to notice it twice.
586    #[test]
587    fn escriba_tui_chrome_converges_with_fleet() {
588        use ishou_tokens::{FleetTheme, convergence::Guard};
589        let chrome_theme = FleetTheme::prescribed_default();
590        Guard::for_app("escriba-tui").expect_theme(chrome_theme).run();
591    }
592
593    /// The chrome helpers must actually paint the fleet theme — not merely
594    /// agree with it in the assertion above. Pins the buffer ground to the
595    /// prescribed chrome's background so a renderer that silently kept a
596    /// different palette would fail here even if the Guard passed.
597    #[test]
598    fn buffer_ground_is_the_prescribed_chrome() {
599        let c = ChromePalette::prescribed();
600        assert_eq!(buffer_style().bg, Some(rgb(c.background)));
601        assert_eq!(buffer_style().fg, Some(rgb(c.text)));
602    }
603}