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/// The highlight ecosystem, built ONCE per thread.
27///
28/// `build_ecosystem` constructs tree-sitter hosts; doing that per frame would
29/// make every keystroke pay for grammar registration. The GPU face caches it
30/// on the renderer struct — this face has no such struct, its `draw_frame`
31/// takes `&EditorState`, so the cache lives here.
32fn ecosystem() -> &'static hikari_core::Ecosystem {
33    use std::sync::OnceLock;
34    static ECO: OnceLock<hikari_core::Ecosystem> = OnceLock::new();
35    ECO.get_or_init(escriba_ts::build_ecosystem)
36}
37
38/// Per-line syntax colouring for the visible window: for each row, the
39/// `(start_col, end_col, colour)` runs in CHARACTER columns.
40///
41/// Highlighted over the whole visible slice rather than line by line, exactly
42/// as the GPU face does. Per-line highlighting is easier and wrong: a block
43/// comment or a multi-line string only reads correctly when the highlighter
44/// sees the lines together, and the two faces disagreeing about that is the
45/// drift this repo keeps paying for.
46fn syntax_runs(
47    lines: &[String],
48    path: &str,
49    theme: &escriba_ui::syntax::ChromeSyntax,
50) -> Vec<Vec<(usize, usize, ishou_tokens::Rgb)>> {
51    use hikari_core::Theme as _;
52    let mut text = String::new();
53    let mut starts = Vec::with_capacity(lines.len());
54    for l in lines {
55        starts.push(text.len());
56        text.push_str(l);
57        text.push('\n');
58    }
59    let mut out = vec![Vec::new(); lines.len()];
60    let hl = ecosystem().highlighter_for_path(path);
61    for span in hl.highlight(&text) {
62        let r = span.span.range();
63        let c = theme.color(span.class);
64        let rgb = ishou_tokens::Rgb::new(c.r, c.g, c.b);
65        // Which row does this span start on, and where within it?
66        let Some(row) = starts.iter().rposition(|s| *s <= r.start) else {
67            continue;
68        };
69        let Some(line) = lines.get(row) else { continue };
70        let base = starts[row];
71        // BYTE offsets from the highlighter, CHARACTER columns on screen —
72        // the conversion every multibyte line depends on.
73        let to_col = |byte: usize| line[..byte.min(line.len())].chars().count();
74        let s_col = to_col(r.start.saturating_sub(base));
75        let e_col = to_col(r.end.saturating_sub(base).min(line.len()));
76        if e_col > s_col {
77            out[row].push((s_col, e_col, rgb));
78        }
79    }
80    out
81}
82
83/// How many buffer lines a terminal of `total_height` rows actually shows.
84///
85/// ONE definition, because two is what went wrong. The ratatui face never
86/// wrote `viewport.visible_lines`, on the reasoning that "ratatui auto-picks
87/// up the new size on the next draw" — true of PAINTING and false of the
88/// model. `scroll_to_contain` kept using the constructor's default of 40, so
89/// in any terminal shorter than that the editor believed the cursor was
90/// visible while it had scrolled off the screen. A rendered-frame test now
91/// pins it (`tests/viewport_frame.rs`).
92///
93/// The arithmetic must match `draw_frame`'s split (one row for the status
94/// line) and `draw_buffer`'s own reservation, which is why it lives here
95/// rather than being spelled again in the run loop.
96#[must_use]
97pub fn viewport_rows(total_height: u16) -> u16 {
98    // -1 status line (the layout split), -2 draw_buffer's own reservation.
99    total_height.saturating_sub(3).max(1)
100}
101
102/// Point `state`'s viewport at a terminal of this size.
103///
104/// The ratatui peer of the GPU face's `RenderCallback::resize`. Both faces
105/// have to tell the runtime how much they can show, or the scroll-to-contain
106/// invariant is computed against a window that does not exist.
107pub fn sync_viewport(state: &mut EditorState, width: u16, height: u16) {
108    // Report the frame FIRST — every pane rect is derived from it, so the
109    // solve below must see the new size.
110    state.layout.set_frame(escriba_ui::shikiri::Rect::new(
111        0,
112        0,
113        width,
114        viewport_rows(height),
115    ));
116    // Then give each window ITS pane's size, not the terminal's. Sizing every
117    // window from the whole frame is right for one window and wrong for two:
118    // scroll-to-contain would think a half-height pane could show the whole
119    // screen, and the cursor would sit off the bottom of its own split.
120    //
121    // Widths are the FULL pane width, not minus the gutter — `draw_buffer_in`
122    // subtracts that itself (it depends on the buffer's line count), and the
123    // GPU face splits the same way. Subtracting here too takes it twice.
124    let solved = state.layout.solved();
125    for w in state.layout.windows_mut() {
126        if let Some(r) = solved.rect_of(w.id) {
127            w.viewport.visible_lines = u32::from(r.h).max(1);
128            w.viewport.visible_columns = u32::from(r.w).max(1);
129        }
130    }
131    // A resize moves the WINDOW, not the cursor, so nothing else re-runs
132    // scroll-to-contain. Without this the cursor sits off-screen after a
133    // shrink until the operator happens to move it.
134    state.refollow_cursor();
135}
136
137/// Draw one frame. Call from within `terminal.draw(|f| draw_frame(f, state))`.
138pub fn draw_frame(f: &mut Frame<'_>, state: &EditorState) {
139    let area = f.area();
140    let chunks = RLayout::default()
141        .direction(Direction::Vertical)
142        .constraints([Constraint::Min(3), Constraint::Length(1)])
143        .split(area);
144
145    // The operator's theme, resolved once per frame and handed to every
146    // painter. Read from the EDITOR, not from the fleet default — that is
147    // what makes `(deftheme :preset …)` reach the screen.
148    let chrome = state.chrome();
149
150    // The start screen replaces the buffer pane rather than overlaying it:
151    // there is nothing behind it worth showing (escriba only raises it on
152    // an empty scratch buffer), and an overlay would have to reason about
153    // what it is covering.
154    match state.splash() {
155        Some(splash) => draw_splash(f, chunks[0], splash, &chrome),
156        None => {
157            // One pane per leaf, geometry DERIVED. `solved()` is a pure
158            // function of (tree, frame) — nothing here stores a rect, so a
159            // split and a resize cannot disagree about where a pane is.
160            // Solved against the area we are ACTUALLY painting, not against
161            // a frame remembered from an earlier `sync_viewport` call.
162            //
163            // Reading the stored frame made rendering depend on call order:
164            // a face that had not reported its size yet solved to a 0x0
165            // frame, every pane came back zero-area, and the screen went
166            // BLANK with nothing to indicate why. The area is right here in
167            // the draw; taking it from anywhere else is a second source of
168            // truth for the same number.
169            let solved = escriba_ui::shikiri::solve(
170                state.layout.tree(),
171                escriba_ui::shikiri::Rect::new(0, 0, chunks[0].width, chunks[0].height),
172            );
173            for (id, r) in &solved.panes {
174                // A degraded frame yields zero-area panes; skip rather than
175                // paint into nothing. This is the stated limit of `solve`.
176                if r.w == 0 || r.h == 0 {
177                    continue;
178                }
179                let area = ratatui::layout::Rect {
180                    x: chunks[0].x + r.x,
181                    y: chunks[0].y + r.y,
182                    width: r.w.min(chunks[0].width.saturating_sub(r.x)),
183                    height: r.h.min(chunks[0].height.saturating_sub(r.y)),
184                };
185                draw_pane(f, area, state, *id, &chrome);
186            }
187            for rule in &solved.rules {
188                draw_rule(f, chunks[0], rule, &chrome);
189            }
190        }
191    }
192    draw_status_line(f, chunks[1], state, &chrome);
193    // The picker floats OVER the pane — painted last so it occludes, and
194    // outside the splash/buffer match because it is not an alternative to
195    // either. This is the first real overlay; the start screen replaces its
196    // pane rather than floating, which is why it could never have proven
197    // occlusion.
198    if let Some(p) = state.picker() {
199        draw_picker(f, chunks[0], p, &chrome);
200    }
201}
202
203/// Paint the start screen.
204///
205/// All the layout arithmetic lives in `escriba_ui::splash`; this walks the
206/// rows it hands back and colors each span by ROLE. That is the whole reason
207/// the model exists — the GPU and text faces run the same two loops over the
208/// same rows, so the three faces cannot lay the screen out three ways.
209fn draw_splash(
210    f: &mut Frame<'_>,
211    area: ratatui::layout::Rect,
212    splash: &escriba_ui::splash::Splash,
213    chrome: &ChromePalette,
214) {
215    let ground = Style::default()
216        .fg(rgb(chrome.text))
217        .bg(rgb(chrome.background));
218    f.render_widget(Block::default().borders(Borders::NONE).style(ground), area);
219
220    for row in splash.rows(area.width, area.height) {
221        let spans: Vec<Span<'static>> = row
222            .spans
223            .iter()
224            .map(|s| {
225                Span::styled(
226                    s.text.clone(),
227                    ground.fg(rgb(s.role.color(chrome))).add_modifier(
228                        // The wordmark and the menu keys carry the weight;
229                        // everything else stays quiet so they can.
230                        if matches!(
231                            s.role,
232                            escriba_ui::splash::SplashRole::Art
233                                | escriba_ui::splash::SplashRole::MenuKey
234                        ) {
235                            Modifier::BOLD
236                        } else {
237                            Modifier::empty()
238                        },
239                    ),
240                )
241            })
242            .collect();
243        let line_area = ratatui::layout::Rect {
244            x: area.x + row.col,
245            y: area.y + row.row,
246            width: area.width.saturating_sub(row.col),
247            height: 1,
248        };
249        f.render_widget(Paragraph::new(Line::from(spans)).style(ground), line_area);
250    }
251}
252
253/// Paint the picker as a centred floating panel.
254fn draw_picker(
255    f: &mut Frame<'_>,
256    area: ratatui::layout::Rect,
257    picker: &escriba_ui::picker::Picker,
258    chrome: &ChromePalette,
259) {
260    // Centred, and bounded so it never exceeds its pane — a surface that can
261    // be drawn outside its container is a panic waiting for a small terminal.
262    let w = area.width.saturating_mul(3) / 4;
263    let h = (picker.visible_count() as u16 + 3)
264        .min(area.height.saturating_sub(2))
265        .max(3);
266    let panel = ratatui::layout::Rect {
267        x: area.x + area.width.saturating_sub(w) / 2,
268        y: area.y + area.height.saturating_sub(h) / 2,
269        width: w.min(area.width),
270        height: h.min(area.height),
271    };
272
273    let ground = Style::default()
274        .fg(rgb(chrome.text))
275        .bg(rgb(chrome.surface));
276    f.render_widget(ratatui::widgets::Clear, panel);
277
278    let mut lines: Vec<Line<'static>> = Vec::with_capacity(panel.height as usize);
279    let mut title = String::from(" ");
280    title.push_str(picker.source().title());
281    title.push_str("  ");
282    title.push_str(picker.query());
283    lines.push(Line::from(Span::styled(
284        title,
285        ground.fg(rgb(chrome.accent)).add_modifier(Modifier::BOLD),
286    )));
287    for (label, selected) in picker.rows() {
288        let mut row = String::with_capacity(label.len() + 2);
289        row.push_str(if selected { "> " } else { "  " });
290        row.push_str(&label);
291        lines.push(Line::from(Span::styled(
292            row,
293            if selected {
294                ground.fg(rgb(chrome.background)).bg(rgb(chrome.accent))
295            } else {
296                ground
297            },
298        )));
299    }
300
301    let block = Block::default()
302        .borders(Borders::ALL)
303        .border_style(Style::default().fg(rgb(chrome.accent)))
304        .style(ground);
305    f.render_widget(Paragraph::new(lines).block(block), panel);
306}
307
308/// Paint the separator between two panes.
309///
310/// A one-cell rule, dim, in the theme's own `text_dim`. Drawn from the SOLVED
311/// rules rather than inferred from pane edges: inferring means two places
312/// deciding where the boundary is, and they disagree the moment a pane is
313/// zero-width.
314fn draw_rule(
315    f: &mut Frame<'_>,
316    origin: ratatui::layout::Rect,
317    rule: &escriba_ui::shikiri::Rule,
318    chrome: &ChromePalette,
319) {
320    // HEAVY box-drawing, deliberately. The GUTTER already draws a light
321    // `│` between the line numbers and the text, so a light pane separator
322    // is indistinguishable from it — the operator cannot tell "this is
323    // another window" from "this is the same window's gutter". One glyph
324    // meaning two things is a reader's problem whichever they learn first,
325    // which this codebase already learned from the `●` finding-mark that
326    // collided with the status line's modified indicator.
327    let glyph = match rule.axis {
328        escriba_ui::shikiri::Axis::Stacked => "\u{2501}", // ━
329        escriba_ui::shikiri::Axis::SideBySide => "\u{2503}", // ┃
330    };
331    let r = rule.rect;
332    let area = ratatui::layout::Rect {
333        x: origin.x + r.x,
334        y: origin.y + r.y,
335        width: r.w.min(origin.width.saturating_sub(r.x)),
336        height: r.h.min(origin.height.saturating_sub(r.y)),
337    };
338    if area.width == 0 || area.height == 0 {
339        return;
340    }
341    let line: String = glyph.repeat(area.width as usize);
342    let style = Style::default()
343        .fg(rgb(chrome.text_dim))
344        .bg(rgb(chrome.background));
345    for y in 0..area.height {
346        let row = ratatui::layout::Rect {
347            y: area.y + y,
348            height: 1,
349            ..area
350        };
351        f.render_widget(
352            Paragraph::new(Line::from(Span::styled(line.clone(), style))),
353            row,
354        );
355    }
356}
357
358/// Paint ONE pane — the window `id`, in `area`.
359fn draw_pane(
360    f: &mut Frame<'_>,
361    area: ratatui::layout::Rect,
362    state: &EditorState,
363    id: escriba_core::WindowId,
364    chrome: &ChromePalette,
365) {
366    let Some(win) = state.layout.windows().find(|w| w.id == id) else {
367        return;
368    };
369    draw_buffer_in(f, area, state, win, chrome);
370}
371
372fn draw_buffer_in(
373    f: &mut Frame<'_>,
374    area: ratatui::layout::Rect,
375    state: &EditorState,
376    win: &escriba_ui::Window,
377    chrome: &ChromePalette,
378) {
379    // THIS window's buffer, not the editor's active one. Reading
380    // `state.active` here would paint every pane with the focused pane's
381    // file — a split showing two different files is the entire point.
382    let Some(buf) = state.buffers.get(win.buffer_id) else {
383        f.render_widget(
384            Paragraph::new("<no buffer>").style(error_style(chrome)),
385            area,
386        );
387        return;
388    };
389
390    // …and THIS window's scroll position. Two panes on one buffer scroll
391    // independently; that is what makes `:sp` useful for comparing two places
392    // in one file.
393    let top = win.viewport.top_line;
394    let left = win.viewport.left_column;
395    // The gutter's width derives from the buffer, so every line of THIS
396    // buffer agrees and the text column cannot move while scrolling. The old
397    // comment here claimed a fixed 7 columns; it was never 7 (the mark cell
398    // made it 8) and it was never fixed (a 10 000-line file needs 9).
399    let line_count = buf.line_count();
400    let gutter_cols = escriba_ui::gutter::gutter_width(line_count);
401    // Sized from the PANE, not the terminal — `area` is what this window
402    // actually got from `solve`.
403    let vis_cols = (area.width as usize).saturating_sub(gutter_cols);
404    let visible = area.height.saturating_sub(2).max(1);
405    // The cursor is painted in the FOCUSED pane only. An unfocused pane
406    // showing a block cursor would claim a focus it does not have, and with
407    // two panes on one buffer both would appear active.
408    let focused = win.id == state.layout.active();
409    let cursor = if focused {
410        state.cursor()
411    } else {
412        escriba_core::Position::new(u32::MAX, u32::MAX)
413    };
414    // The cursor's on-screen shape is derived from the active mode through
415    // the one typed `Mode::cursor_shape` function — block in Normal/Command,
416    // bar in Insert, underline in Visual. Both backends read it from there,
417    // so the shapes can't drift apart.
418    let shape = state.modal.mode().cursor_shape();
419
420    // The visible slice, gathered BEFORE painting so the highlighter sees the
421    // rows together — a block comment or multi-line string only reads right
422    // that way.
423    let visible_text: Vec<String> = (0..visible as u32)
424        .map_while(|row| {
425            let ln = top + row;
426            (ln < buf.line_count()).then(|| {
427                buf.line(ln)
428                    .unwrap_or_default()
429                    .trim_end_matches('\n')
430                    .trim_end_matches('\r')
431                    .to_string()
432            })
433        })
434        .collect();
435    let path = buf
436        .path
437        .as_ref()
438        .map(|p| p.to_string_lossy().into_owned())
439        .unwrap_or_default();
440    let syntax = syntax_runs(
441        &visible_text,
442        &path,
443        &escriba_ui::syntax::ChromeSyntax::new(*chrome),
444    );
445
446    let mut lines: Vec<Line<'static>> = Vec::with_capacity(visible as usize);
447    for row in 0..visible as u32 {
448        let ln = top + row;
449        if ln >= buf.line_count() {
450            break;
451        }
452        let Some(line_str) = buf.line(ln) else {
453            continue;
454        };
455        let text = line_str
456            .trim_end_matches('\n')
457            .trim_end_matches('\r')
458            .to_string();
459        // Search matches are DOCUMENT char offsets; the renderer paints
460        // COLUMNS. Translate once per line via the line's own start offset,
461        // so no offset arithmetic leaks into the span builder.
462        let line_start = buf
463            .position_to_char(escriba_core::Position::new(ln, 0))
464            .unwrap_or(0);
465        let line_len = text.chars().count();
466        let hl: Vec<(usize, usize)> = state
467            .search
468            .highlights()
469            .iter()
470            .filter_map(|m| {
471                // Clip the match to this line; a multi-line match paints its
472                // overlapping part on each line it crosses.
473                let s = m.start.saturating_sub(line_start);
474                let e = m.end.saturating_sub(line_start);
475                (m.end > line_start && m.start < line_start + line_len + 1)
476                    .then(|| (s.min(line_len), e.min(line_len)))
477            })
478            .filter(|(s, e)| e > s)
479            .collect();
480        // The worst finding on this line, if any — one cell, always, so a
481        // diagnostic arriving does not shift every line sideways.
482        let mark = state
483            .results
484            .worst_on_line(&state.world(), state.active, ln);
485        lines.push(line_with_gutter(
486            chrome,
487            mark,
488            ln,
489            line_count,
490            syntax.get(row as usize).map_or(&[][..], Vec::as_slice),
491            &text,
492            cursor,
493            left as usize,
494            vis_cols,
495            shape,
496            &hl,
497        ));
498    }
499
500    let block = Block::default()
501        .borders(Borders::NONE)
502        .style(buffer_style(chrome));
503    f.render_widget(Paragraph::new(lines).block(block), area);
504}
505
506/// Render one line with a gutter, sliced horizontally to the visible
507/// column window `[left, left + vis_cols)`. Slicing is char-based (not
508/// byte-based) so multibyte text stays aligned, and the cursor's on-screen
509/// column is computed relative to `left` so the cursor glyph tracks the
510/// horizontal scroll.
511fn line_with_gutter(
512    chrome: &ChromePalette,
513    mark: Option<escriba_shirube::Severity>,
514    ln: u32,
515    // The buffer's total line count — the gutter's width derives from it, so
516    // every line of one buffer agrees. Passed in rather than read here so a
517    // test can render a line without standing up an `EditorState`.
518    line_count: u32,
519    // Syntax colouring for THIS line, in character columns.
520    syntax: &[(usize, usize, ishou_tokens::Rgb)],
521    text: &str,
522    cursor: escriba_core::Position,
523    left: usize,
524    vis_cols: usize,
525    shape: CursorShape,
526    highlights: &[(usize, usize)],
527) -> Line<'static> {
528    // The gutter is COMPOSED by `escriba_ui::gutter`, not here. This face's
529    // only job is to turn each cell's role into a ratatui `Style` — which is
530    // what makes the GPU face able to paint the identical gutter by answering
531    // the same question in its own colours.
532    let mut spans: Vec<Span<'static>> = escriba_ui::gutter::gutter_cells(ln, mark, line_count)
533        .into_iter()
534        .map(|c| {
535            let style = match c.role {
536                escriba_ui::gutter::GutterRole::Mark(sev) => {
537                    Style::default().fg(rgb(escriba_ui::chrome::severity_color(chrome, sev)))
538                }
539                _ => muted_style(chrome),
540            };
541            Span::styled(c.text, style)
542        })
543        .collect();
544
545    let chars: Vec<char> = text.chars().collect();
546    // The slice of characters actually visible in this window.
547    let visible: Vec<char> = chars.iter().copied().skip(left).take(vis_cols).collect();
548
549    // One style slot per visible cell. Painting cell-by-cell and coalescing
550    // afterwards is what lets the cursor and any number of search matches
551    // overlap on the same line — the previous before/cursor/after split could
552    // only ever express ONE styled region, so highlights had nowhere to go.
553    let mut cell_styles: Vec<Option<Style>> = vec![None; visible.len()];
554    // Syntax FIRST, so a search match paints over it. The precedence is
555    // deliberate and reads bottom-up at the call sites below: syntax, then
556    // search, then the cursor — each one is a more urgent thing to see than
557    // the one under it.
558    for &(ss, se, colour) in syntax {
559        for col in ss..se {
560            if col >= left {
561                if let Some(slot) = cell_styles.get_mut(col - left) {
562                    *slot = Some(Style::default().fg(rgb(colour)));
563                }
564            }
565        }
566    }
567    for &(hs, he) in highlights {
568        for col in hs..he {
569            if col >= left {
570                if let Some(slot) = cell_styles.get_mut(col - left) {
571                    *slot = Some(search_match_style(chrome));
572                }
573            }
574        }
575    }
576
577    // The cursor wins over a highlight on its own cell — you must always be
578    // able to see where you are, even sitting on a match.
579    let cursor_here = (ln == cursor.line && cursor.column as usize >= left)
580        .then(|| cursor.column as usize - left);
581
582    if let Some(rel) = cursor_here {
583        if rel >= visible.len() {
584            push_runs(&mut spans, &visible, &cell_styles);
585            spans.extend(cursor_spans(chrome, ' ', shape));
586            return Line::from(spans);
587        }
588        push_runs(&mut spans, &visible[..rel], &cell_styles[..rel]);
589        spans.extend(cursor_spans(chrome, visible[rel], shape));
590        push_runs(&mut spans, &visible[rel + 1..], &cell_styles[rel + 1..]);
591    } else {
592        push_runs(&mut spans, &visible, &cell_styles);
593    }
594
595    Line::from(spans)
596}
597
598/// Emit `chars` as the fewest spans that preserve `styles`, merging adjacent
599/// cells that share a style. Without the merge a 200-column line would emit
600/// 200 single-char spans every frame.
601fn push_runs(spans: &mut Vec<Span<'static>>, chars: &[char], styles: &[Option<Style>]) {
602    debug_assert_eq!(chars.len(), styles.len(), "one style slot per cell");
603    let mut i = 0;
604    while i < chars.len() {
605        let style = styles.get(i).copied().flatten();
606        let mut j = i + 1;
607        while j < chars.len() && styles.get(j).copied().flatten() == style {
608            j += 1;
609        }
610        let run: String = chars[i..j].iter().collect();
611        spans.push(match style {
612            Some(st) => Span::styled(run, st),
613            None => Span::raw(run),
614        });
615        i = j;
616    }
617}
618
619/// Render the cell under the cursor in its per-mode [`CursorShape`].
620///
621/// - [`CursorShape::Block`]: fill the cell (dark glyph on the cursor color)
622///   — the Normal/Command "you are here" indicator.
623/// - [`CursorShape::Bar`]: a thin vertical bar drawn BEFORE the glyph
624///   (Insert mode's between-glyphs caret), the glyph itself left plain.
625/// - [`CursorShape::Underline`]: the glyph with an underline modifier
626///   (Visual mode), so the highlighted selection stays readable.
627fn cursor_spans(c: &ChromePalette, under: char, shape: CursorShape) -> Vec<Span<'static>> {
628    match shape {
629        CursorShape::Block => vec![Span::styled(under.to_string(), cursor_block_style(c))],
630        CursorShape::Bar => vec![
631            Span::styled("▏".to_string(), cursor_bar_style(c)),
632            Span::raw(under.to_string()),
633        ],
634        CursorShape::Underline => vec![Span::styled(under.to_string(), cursor_underline_style(c))],
635    }
636}
637
638fn draw_status_line(
639    f: &mut Frame<'_>,
640    area: ratatui::layout::Rect,
641    state: &EditorState,
642    chrome: &ChromePalette,
643) {
644    // ONE model, read once. The pill and the prompt both derive from it, so
645    // they cannot describe two different states of the same editor.
646    let model = state.status_model();
647    let pos = format!("{}:{}", state.cursor().line + 1, state.cursor().column + 1);
648    // Status glyphs are the BORN fleet vocabulary (`ishou_tokens::EscribaSignals`),
649    // not hand-picked literals. Single-width `Glyph` mode keeps the
650    // status-line column alignment-safe.
651    let sig = EscribaSignals::prescribed();
652
653    // The pill leads with the OPEN PROMPT'S sigil when there is one, and the
654    // mode glyph otherwise. Search reuses `Mode::Command` (vim's `/` IS the
655    // command line), so painting the raw mode drew `: COMMAND` for a search
656    // — a status line character-for-character identical to the one `:`
657    // produces. That is how a fully working search reads as "pressing `/`
658    // put me in `:` mode": the editor was right and its report was wrong.
659    let mut pill = String::with_capacity(16);
660    pill.push(' ');
661    match model.pill_sigil() {
662        Some(sigil) => pill.push(sigil),
663        None => pill.push_str(mode_signal(&sig, state.modal.mode()).render(SignalMode::Glyph)),
664    }
665    pill.push(' ');
666    pill.push_str(model.mode_label());
667    pill.push(' ');
668    let mode_span = Span::styled(pill, pill_style_for(chrome, &model, state.modal.mode()));
669
670    // vim puts the command line bottom-LEFT, where the eye already is. This
671    // prompt used to render at the far RIGHT, wedged between the match count
672    // and the cursor position — `/foo` was on screen and nobody saw it. When
673    // a prompt is open it takes the slot the path occupies, the way vim's
674    // cmdline covers the status text.
675    let context_span = if model.pill_sigil().is_some() {
676        let mut line = String::from(" ");
677        model.render_prompt_into(&mut line);
678        line.push(' ');
679        Span::styled(line, cmd_style(chrome))
680    } else {
681        let path = state
682            .buffers
683            .get(state.active)
684            .and_then(|b| b.path.clone())
685            .map_or("scratch".to_string(), |p| p.display().to_string());
686        let modified = state.buffers.get(state.active).is_some_and(|b| b.modified);
687        let modified_indicator = if modified {
688            format!(" {}", sig.modified.render(SignalMode::Glyph))
689        } else {
690            String::new()
691        };
692        Span::styled(
693            format!(" {path}{modified_indicator} "),
694            status_style(chrome),
695        )
696    };
697    let pos_span = Span::styled(format!(" {pos} "), status_style(chrome));
698
699    // `[3/17]`. Both halves were already computed by the engine and both were
700    // discarded; the denominator is what turns "press n until it looks right"
701    // into a decision — `[1/1]` says a rename is safe, `[1/240]` says narrow
702    // the pattern first. Silent when there is nothing to count.
703    let count = model.count;
704    let count_span = if count.is_idle() {
705        Span::raw("")
706    } else {
707        let mut c = String::from(" ");
708        count.render_into(&mut c);
709        c.push(' ');
710        Span::styled(c, status_style(chrome))
711    };
712
713    // Layout: [pill] [prompt-or-path] … (flex) … [count] [pos]
714    let available = usize::from(area.width);
715    let left = mode_span.content.chars().count() + context_span.content.chars().count();
716    let right = count_span.content.chars().count() + pos_span.content.chars().count();
717    let pad = available.saturating_sub(left + right);
718
719    let line = Line::from(vec![
720        mode_span,
721        context_span,
722        Span::raw(" ".repeat(pad)),
723        count_span,
724        pos_span,
725    ]);
726    f.render_widget(Paragraph::new(line).style(status_style(chrome)), area);
727}
728
729// ─── Styles — Vellum (warm aged-paper Nord-matte) ───────────────────────
730//
731// Every chrome color resolves through `escriba_ui::chrome::ChromePalette`
732// — the one theme seam, shared with the GPU backend so the two faces cannot
733// drift apart. Colors are named by ROLE (text / surface / cursor / error),
734// never by a theme's own token spelling, which is what lets the theme change
735// without touching a single call site here.
736//
737// Each helper takes the LIVE palette rather than reading
738// `ChromePalette::prescribed()` for itself. That parameter is the whole
739// theming fix: while these read the prescribed value directly, an operator
740// could author `(deftheme :preset "vellum")`, watch it parse, validate and
741// resolve to a real `FleetTheme` — and see the editor paint Nord anyway,
742// because nothing downstream consumed it. A palette that arrives as an
743// argument cannot be ignored.
744
745fn buffer_style(c: &ChromePalette) -> Style {
746    Style::default().fg(rgb(c.text)).bg(rgb(c.background))
747}
748
749fn muted_style(c: &ChromePalette) -> Style {
750    Style::default().fg(rgb(c.text_dim)) // comment / gutter
751}
752
753/// Block cursor (Normal / Command) — dark glyph filled onto the cursor
754/// color, the "you are here" cell.
755fn cursor_block_style(c: &ChromePalette) -> Style {
756    Style::default()
757        .fg(rgb(c.background)) // ground-colored text on the cursor
758        .bg(rgb(c.cursor))
759        .add_modifier(Modifier::BOLD)
760}
761
762/// Bar cursor (Insert) — the thin vertical caret drawn between glyphs,
763/// colored in the cursor accent.
764fn cursor_bar_style(c: &ChromePalette) -> Style {
765    Style::default()
766        .fg(rgb(c.cursor))
767        .add_modifier(Modifier::BOLD)
768}
769
770/// Underline cursor (Visual) — the glyph kept legible with an underline in
771/// the cursor accent.
772/// Style for a search match under `hlsearch`.
773///
774/// Reversed against the `warning` role rather than a literal colour: it reads
775/// as "look here" without colliding with `cursor` (which must stay
776/// distinguishable when the cursor sits ON a match) or with `error`. Sourced
777/// from ChromePalette so it follows the fleet theme like every other style
778/// here — a hardcoded hex would be the one span that ignores the theme.
779fn search_match_style(c: &ChromePalette) -> Style {
780    Style::default().fg(rgb(c.background)).bg(rgb(c.warning))
781}
782
783fn cursor_underline_style(c: &ChromePalette) -> Style {
784    Style::default()
785        .fg(rgb(c.cursor))
786        .add_modifier(Modifier::UNDERLINED)
787        .add_modifier(Modifier::BOLD)
788}
789
790fn status_style(c: &ChromePalette) -> Style {
791    // Was a raw `Color::Rgb(0xCD, 0xC7, 0xB6)` literal ("statusline_fg,
792    // Vellum extra") — the one genuinely hardcoded color in this file, and
793    // dead weight the moment the theme moved. It is now the `text` role.
794    Style::default().fg(rgb(c.text)).bg(rgb(c.surface))
795}
796
797fn cmd_style(c: &ChromePalette) -> Style {
798    Style::default()
799        .fg(rgb(c.warning))
800        .bg(rgb(c.surface))
801        .add_modifier(Modifier::BOLD)
802}
803
804fn error_style(c: &ChromePalette) -> Style {
805    Style::default().fg(rgb(c.error)).bg(rgb(c.background))
806}
807
808/// Map an editor [`Mode`](escriba_core::Mode) to its fleet
809/// [`Signal`](ishou_tokens::Signal) from [`EscribaSignals`].
810///
811/// `VisualLine` shares `mode_visual` with `Visual` — the fleet signal
812/// set has one visual signal, matching how [`mode_style_for`] groups the
813/// two under one pill color.
814fn mode_signal(sig: &EscribaSignals, mode: escriba_core::Mode) -> &ishou_tokens::Signal {
815    match mode {
816        escriba_core::Mode::Normal => &sig.mode_normal,
817        escriba_core::Mode::Insert => &sig.mode_insert,
818        escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => &sig.mode_visual,
819        escriba_core::Mode::Command => &sig.mode_command,
820    }
821}
822
823fn mode_style_for(c: &ChromePalette, mode: escriba_core::Mode) -> Style {
824    // Mode pills — ground-colored text on a role-colored field:
825    // Normal info, Insert success, Visual accent, Command warning. Naming
826    // the ROLE rather than the hue is what keeps these correct across
827    // themes: on Nord `info` is frost blue, on Vellum it was ice cyan, and
828    // neither call site has to know.
829    let bg = match mode {
830        escriba_core::Mode::Normal => c.info,
831        escriba_core::Mode::Insert => c.success,
832        escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => c.accent,
833        escriba_core::Mode::Command => c.warning,
834    };
835    Style::default()
836        .fg(rgb(c.background))
837        .bg(rgb(bg))
838        .add_modifier(Modifier::BOLD)
839}
840
841/// The pill's style, chosen from the status MODEL rather than the raw mode.
842///
843/// A search and an ex-command share `Mode::Command`, so [`mode_style_for`]
844/// alone paints them identically — same colour, and (before this) the same
845/// `: COMMAND` text. The search gets the accent field so the two prompts are
846/// distinguishable at a glance, not only by reading the label.
847fn pill_style_for(
848    c: &ChromePalette,
849    model: &escriba_runtime::StatusModel<'_>,
850    mode: escriba_core::Mode,
851) -> Style {
852    if model.prompt.is_search() {
853        return Style::default()
854            .fg(rgb(c.background))
855            .bg(rgb(c.accent))
856            .add_modifier(Modifier::BOLD);
857    }
858    mode_style_for(c, mode)
859}
860
861#[cfg(test)]
862mod tests {
863
864    // ── search highlight rendering ────────────────────────────────────
865
866    /// The palette the render tests paint with. A FIXED theme, not the
867    /// live one: these assert LAYOUT and span structure, and pinning them
868    /// to whatever the fleet currently prescribes would make them rewrite
869    /// themselves on every theme move (which is exactly what happened to
870    /// the mode-pill test before it started asserting roles).
871    fn chrome() -> ChromePalette {
872        ChromePalette::prescribed()
873    }
874
875    fn styles_of(spans: &[Span<'static>]) -> Vec<(String, bool)> {
876        // (text, is-highlighted) — comparing against the exact Style would
877        // pin the palette, which is a theming concern, not a layout one.
878        spans
879            .iter()
880            .map(|sp| {
881                (
882                    sp.content.to_string(),
883                    sp.style.bg == search_match_style(&chrome()).bg,
884                )
885            })
886            .collect()
887    }
888
889    #[test]
890    fn push_runs_merges_adjacent_cells_of_equal_style() {
891        // A 200-column line must not emit 200 spans per frame.
892        let chars: Vec<char> = "aaaabbbb".chars().collect();
893        let mut styles = vec![None; 8];
894        for slot in styles.iter_mut().take(4) {
895            *slot = Some(search_match_style(&chrome()));
896        }
897        let mut spans = vec![];
898        push_runs(&mut spans, &chars, &styles);
899        assert_eq!(spans.len(), 2, "one span per run, not per char");
900        assert_eq!(spans[0].content, "aaaa");
901        assert_eq!(spans[1].content, "bbbb");
902    }
903
904    #[test]
905    fn push_runs_on_empty_input_emits_nothing() {
906        let mut spans = vec![];
907        push_runs(&mut spans, &[], &[]);
908        assert!(spans.is_empty());
909    }
910
911    #[test]
912    fn a_match_is_painted_and_the_rest_is_not() {
913        // "hello world", match on "world" (cols 6..11), cursor elsewhere.
914        let line = line_with_gutter(
915            &chrome(),
916            None,
917            0,
918            64,
919            &[],
920            "hello world",
921            escriba_core::Position::new(9, 0), // cursor on another line
922            0,
923            80,
924            CursorShape::Block,
925            &[(6, 11)],
926        );
927        let painted: Vec<String> = styles_of(&line.spans)
928            .into_iter()
929            .filter(|(_, hl)| *hl)
930            .map(|(t, _)| t)
931            .collect();
932        assert_eq!(
933            painted,
934            vec!["world".to_string()],
935            "exactly the match is lit"
936        );
937    }
938
939    #[test]
940    fn two_matches_on_one_line_are_both_painted() {
941        // The old before/cursor/after split could express only ONE styled
942        // region — this is the case it structurally could not render.
943        let line = line_with_gutter(
944            &chrome(),
945            None,
946            0,
947            64,
948            &[],
949            "foo bar foo",
950            escriba_core::Position::new(9, 0),
951            0,
952            80,
953            CursorShape::Block,
954            &[(0, 3), (8, 11)],
955        );
956        let painted: Vec<String> = styles_of(&line.spans)
957            .into_iter()
958            .filter(|(_, hl)| *hl)
959            .map(|(t, _)| t)
960            .collect();
961        assert_eq!(painted, vec!["foo".to_string(), "foo".to_string()]);
962    }
963
964    #[test]
965    fn the_cursor_stays_visible_when_sitting_on_a_match() {
966        // A highlight must never swallow the cursor cell, or you lose your
967        // place the moment you land on a match — which is always, after `n`.
968        let line = line_with_gutter(
969            &chrome(),
970            None,
971            0,
972            64,
973            &[],
974            "foo bar",
975            escriba_core::Position::new(0, 1),
976            0,
977            80,
978            CursorShape::Block,
979            &[(0, 3)],
980        );
981        let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
982        assert!(
983            texts.contains(&"o".to_string()),
984            "cursor cell rendered alone: {texts:?}"
985        );
986    }
987
988    #[test]
989    fn highlights_respect_horizontal_scroll() {
990        // Scrolled right by 4: the match at cols 6..11 must shift left by 4.
991        let line = line_with_gutter(
992            &chrome(),
993            None,
994            0,
995            64,
996            &[],
997            "hello world",
998            escriba_core::Position::new(9, 0),
999            4,
1000            80,
1001            CursorShape::Block,
1002            &[(6, 11)],
1003        );
1004        let painted: Vec<String> = styles_of(&line.spans)
1005            .into_iter()
1006            .filter(|(_, hl)| *hl)
1007            .map(|(t, _)| t)
1008            .collect();
1009        assert_eq!(
1010            painted,
1011            vec!["world".to_string()],
1012            "still exactly the match"
1013        );
1014    }
1015
1016    #[test]
1017    fn no_highlights_renders_a_plain_line() {
1018        let line = line_with_gutter(
1019            &chrome(),
1020            None,
1021            0,
1022            64,
1023            &[],
1024            "hello world",
1025            escriba_core::Position::new(9, 0),
1026            0,
1027            80,
1028            CursorShape::Block,
1029            &[],
1030        );
1031        assert!(
1032            styles_of(&line.spans).iter().all(|(_, hl)| !hl),
1033            "nothing lit"
1034        );
1035    }
1036    use super::*;
1037    use escriba_core::Mode;
1038
1039    /// Forcing function: the status-line mode glyphs are sourced from the
1040    /// fleet `EscribaSignals` vocabulary, not hand-picked literals.
1041    #[test]
1042    fn mode_glyphs_are_fleet_signals() {
1043        let sig = EscribaSignals::prescribed();
1044        assert_eq!(
1045            mode_signal(&sig, Mode::Normal).render(SignalMode::Glyph),
1046            "◆"
1047        );
1048        assert_eq!(
1049            mode_signal(&sig, Mode::Insert).render(SignalMode::Glyph),
1050            "▸"
1051        );
1052        assert_eq!(
1053            mode_signal(&sig, Mode::Visual).render(SignalMode::Glyph),
1054            "▮"
1055        );
1056        assert_eq!(
1057            mode_signal(&sig, Mode::VisualLine).render(SignalMode::Glyph),
1058            "▮"
1059        );
1060        assert_eq!(
1061            mode_signal(&sig, Mode::Command).render(SignalMode::Glyph),
1062            ":"
1063        );
1064    }
1065
1066    /// The modified indicator is the fleet `modified` glyph (`●`), not a
1067    /// hand-picked literal.
1068    #[test]
1069    fn modified_indicator_is_fleet_signal() {
1070        let sig = EscribaSignals::prescribed();
1071        assert_eq!(sig.modified.render(SignalMode::Glyph), "●");
1072    }
1073
1074    /// The cursor is rendered in its per-mode shape: a block fills the
1075    /// cell (Normal), a bar precedes the glyph (Insert), an underline marks
1076    /// the glyph (Visual). The shape is selected by `Mode::cursor_shape`.
1077    #[test]
1078    fn cursor_spans_render_per_mode_shape() {
1079        // Block: a single span styled with the cursor BG (block fill).
1080        let block = cursor_spans(&chrome(), 'a', CursorShape::Block);
1081        assert_eq!(block.len(), 1);
1082        assert_eq!(block[0].content, "a");
1083        // The cursor ROLE, not a theme's own token — this assertion used to
1084        // name `VellumPalette::vellum().green_bright`, which pinned the test
1085        // to one theme and would have had to change on every theme move.
1086        assert_eq!(block[0].style.bg, Some(rgb(chrome().cursor)));
1087
1088        // Bar: a thin caret span BEFORE the (unstyled) glyph.
1089        let bar = cursor_spans(&chrome(), 'a', CursorShape::Bar);
1090        assert_eq!(bar.len(), 2);
1091        assert_eq!(bar[0].content, "▏");
1092        assert_eq!(bar[1].content, "a");
1093        assert_eq!(bar[1].style.bg, None, "bar leaves the glyph cell unfilled");
1094
1095        // Underline: one glyph span carrying the UNDERLINED modifier.
1096        let under = cursor_spans(&chrome(), 'a', CursorShape::Underline);
1097        assert_eq!(under.len(), 1);
1098        assert!(under[0].style.add_modifier.contains(Modifier::UNDERLINED));
1099    }
1100
1101    /// End-to-end: the shape the buffer pane uses is derived from the live
1102    /// modal mode through the one typed `Mode::cursor_shape` mapping.
1103    #[test]
1104    fn buffer_shape_follows_modal_mode() {
1105        use escriba_core::Mode;
1106        assert_eq!(Mode::Normal.cursor_shape(), CursorShape::Block);
1107        assert_eq!(Mode::Insert.cursor_shape(), CursorShape::Bar);
1108        assert_eq!(Mode::Visual.cursor_shape(), CursorShape::Underline);
1109    }
1110
1111    /// Fleet convergence guard: escriba's TUI chrome paints whatever
1112    /// `ChromePalette::prescribed()` resolves, which is
1113    /// `FleetTheme::prescribed_default()` BY CONSTRUCTION — so this Guard
1114    /// can no longer be satisfied by a stale hand-written constant.
1115    ///
1116    /// It previously hardcoded `FleetTheme::Vellum` here to match a paint
1117    /// path hardwired to `VellumPalette::vellum()`. When the fleet moved its
1118    /// prescribed theme to PlemeDark (Nord), that made the test RED —
1119    /// correctly: escriba really was painting the wrong theme. Asserting the
1120    /// resolved value instead of a literal is what stops that class of drift
1121    /// from needing a human to notice it twice.
1122    #[test]
1123    fn escriba_tui_chrome_converges_with_fleet() {
1124        use ishou_tokens::{FleetTheme, convergence::Guard};
1125        let chrome_theme = FleetTheme::prescribed_default();
1126        Guard::for_app("escriba-tui")
1127            .expect_theme(chrome_theme)
1128            .run();
1129    }
1130
1131    /// The chrome helpers must actually paint the fleet theme — not merely
1132    /// agree with it in the assertion above. Pins the buffer ground to the
1133    /// prescribed chrome's background so a renderer that silently kept a
1134    /// different palette would fail here even if the Guard passed.
1135    #[test]
1136    fn buffer_ground_is_the_prescribed_chrome() {
1137        let c = ChromePalette::prescribed();
1138        assert_eq!(buffer_style(&c).bg, Some(rgb(c.background)));
1139        assert_eq!(buffer_style(&c).fg, Some(rgb(c.text)));
1140    }
1141}