Skip to main content

escriba_render/
splash.rs

1//! ANSI rendering of the start screen — the `--render=text` face.
2//!
3//! All layout comes from `escriba_ui::splash`; this only turns roles into
4//! 24-bit SGR sequences. The GPU face consumes the SAME
5//! [`Splash::screen_chunks`](escriba_ui::splash::Splash::screen_chunks)
6//! stream and turns roles into glyphon `Attrs` instead, which is what keeps
7//! the three faces from laying the screen out three ways.
8
9use escriba_ui::chrome::ChromePalette;
10use escriba_ui::splash::{Splash, SplashRole};
11
12/// Reset every attribute — emitted once at the end so a caller's terminal
13/// is handed back clean.
14const SGR_RESET: &str = "\x1b[0m";
15
16/// Render `splash` on a `width × height` character canvas as ANSI text.
17///
18/// Returns an empty string when the canvas is too small to hold even the
19/// compact wordmark — a caller then falls back to its ordinary frame rather
20/// than printing a mangled one.
21#[must_use]
22pub fn render_splash_ansi(
23    splash: &Splash,
24    chrome: &ChromePalette,
25    width: u16,
26    height: u16,
27) -> String {
28    let chunks = splash.screen_chunks(width, height);
29    if chunks.is_empty() {
30        return String::new();
31    }
32    let mut out = String::with_capacity(chunks.len() * 24);
33    // `None` = nothing emitted yet, so the first colored chunk always
34    // writes its sequence.
35    let mut current: Option<SplashRole> = None;
36    for chunk in &chunks {
37        // Whitespace carries no color, and re-emitting SGR around every
38        // pad run would triple the output for no visible difference.
39        if chunk.text.trim().is_empty() {
40            out.push_str(&chunk.text);
41            continue;
42        }
43        if current != Some(chunk.role) {
44            push_fg(&mut out, chunk.role.color(chrome));
45            current = Some(chunk.role);
46        }
47        out.push_str(&chunk.text);
48    }
49    out.push_str(SGR_RESET);
50    out
51}
52
53/// `ESC[38;2;R;G;Bm` — a 24-bit foreground. Built with `push_str`/`push`
54/// rather than `format!`, per the fleet's typed-emission rule.
55fn push_fg(out: &mut String, c: ishou_tokens::Rgb) {
56    out.push_str("\x1b[38;2;");
57    push_u8(out, c.r);
58    out.push(';');
59    push_u8(out, c.g);
60    out.push(';');
61    push_u8(out, c.b);
62    out.push('m');
63}
64
65fn push_u8(out: &mut String, mut n: u8) {
66    if n == 0 {
67        out.push('0');
68        return;
69    }
70    let mut buf = [0u8; 3];
71    let mut i = buf.len();
72    while n > 0 {
73        i -= 1;
74        buf[i] = b'0' + (n % 10);
75        n /= 10;
76    }
77    out.push_str(core::str::from_utf8(&buf[i..]).unwrap_or("0"));
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use escriba_core::Action;
84    use escriba_ui::splash::SplashEntry;
85
86    fn sample() -> Splash {
87        Splash {
88            art: vec!["ESCRIBA".into()],
89            tagline: "a modal editor".into(),
90            entries: vec![SplashEntry {
91                key: 'q',
92                label: "quit".into(),
93                action: Action::Quit,
94            }],
95            facts: vec!["v0.1.0".into()],
96        }
97    }
98
99    #[test]
100    fn the_screen_survives_the_trip_through_ansi() {
101        let out = render_splash_ansi(&sample(), &ChromePalette::prescribed(), 80, 24);
102        assert!(out.contains("ESCRIBA"), "{out:?}");
103        assert!(out.contains("a modal editor"));
104        assert!(out.contains("quit"));
105        assert!(out.contains("v0.1.0"));
106    }
107
108    #[test]
109    fn color_is_emitted_and_always_reset() {
110        let out = render_splash_ansi(&sample(), &ChromePalette::prescribed(), 80, 24);
111        assert!(out.contains("\x1b[38;2;"), "no 24-bit color emitted");
112        assert!(
113            out.ends_with(SGR_RESET),
114            "a face must hand the terminal back clean",
115        );
116    }
117
118    #[test]
119    fn a_canvas_with_no_room_renders_nothing_at_all() {
120        // Not a partial screen — the caller falls back to its own frame.
121        assert!(render_splash_ansi(&sample(), &ChromePalette::prescribed(), 80, 1).is_empty());
122    }
123
124    #[test]
125    fn push_u8_covers_the_whole_byte_range() {
126        for n in [0u8, 7, 42, 100, 255] {
127            let mut s = String::new();
128            push_u8(&mut s, n);
129            assert_eq!(s, n.to_string());
130        }
131    }
132}