Skip to main content

kintsugi_tui/
splash.rs

1//! The launch splash — an animated wordmark that "fills with gold", the kintsugi
2//! metaphor: a break repaired with molten gold becomes more beautiful than new.
3//!
4//! The animation is honest about the terminal medium. With color, the block
5//! letters fill left-to-right with kintsugi gold; without color (or `NO_COLOR`),
6//! the same sweep is shown by swapping the unfilled cells' glyph (`░` → `█`), so
7//! the motion never depends on color alone. It is purely decorative and any key
8//! skips it.
9
10use ratatui::prelude::*;
11use ratatui::widgets::Paragraph;
12
13/// Total animation frames before the splash hands off to the app.
14pub const FRAMES: usize = 30;
15/// Frames over which the gold "fills" the wordmark (then it holds, shimmering).
16const FILL_FRAMES: usize = 20;
17
18/// Kintsugi gold, and the muted seam it flows into.
19const GOLD: Color = Color::Rgb(212, 175, 55);
20const SEAM: Color = Color::Rgb(90, 90, 90);
21
22const TAGLINE: &str = "a local-first safety layer for AI coding agents";
23
24/// 5-row block glyphs for K I N T S U G I, assembled at render time.
25const GLYPHS: &[[&str; 5]] = &[
26    ["█  █", "█ █ ", "██  ", "█ █ ", "█  █"],      // K
27    ["███", " █ ", " █ ", " █ ", "███"],           // I
28    ["█  █", "██ █", "█ ██", "█  █", "█  █"],      // N
29    ["█████", "  █  ", "  █  ", "  █  ", "  █  "], // T
30    [" ███", "█   ", " ██ ", "   █", "███ "],      // S
31    ["█  █", "█  █", "█  █", "█  █", " ██ "],      // U
32    [" ███", "█   ", "█ ██", "█  █", " ███"],      // G
33    ["███", " █ ", " █ ", " █ ", "███"],           // I
34];
35
36/// Assemble the eight glyphs into five rows separated by a single space column.
37fn wordmark_rows() -> [String; 5] {
38    let mut rows: [String; 5] = Default::default();
39    for (gi, g) in GLYPHS.iter().enumerate() {
40        for (r, row) in rows.iter_mut().enumerate() {
41            if gi > 0 {
42                row.push(' ');
43            }
44            row.push_str(g[r]);
45        }
46    }
47    rows
48}
49
50/// Render the splash at animation `frame` into `area`. `color` gates styling.
51pub fn render(f: &mut Frame, area: Rect, frame: usize, color: bool) {
52    let rows = wordmark_rows();
53    let width = rows[0].chars().count();
54
55    // Too narrow for the block banner: degrade to a simple centered wordmark.
56    if (area.width as usize) < width + 2 {
57        let lines = vec![
58            Line::from(""),
59            Line::from(Span::styled(
60                "KINTSUGI",
61                style_if(color, Style::default().fg(GOLD)).add_modifier(Modifier::BOLD),
62            )),
63            Line::from(Span::styled(TAGLINE, dim_if(color))),
64        ];
65        f.render_widget(
66            Paragraph::new(lines).alignment(Alignment::Center),
67            centered(area, 3),
68        );
69        return;
70    }
71
72    // How far the gold has flowed across the wordmark (in columns).
73    let fill = (frame.min(FILL_FRAMES) as f32 / FILL_FRAMES as f32 * width as f32).round() as usize;
74    // A one-cell bright "leading edge" shimmer once filled.
75    let done = frame >= FILL_FRAMES;
76
77    let mut lines: Vec<Line> = Vec::new();
78    // The brand mark — a tile rejoined by a golden seam — above the wordmark,
79    // when there's vertical room. Mirrors the web/README logo in the terminal.
80    if area.height >= 16 {
81        for ml in mark_lines(color) {
82            lines.push(ml);
83        }
84        lines.push(Line::from(""));
85    }
86    for row in &rows {
87        let mut spans = Vec::new();
88        for (col, ch) in row.chars().enumerate() {
89            if ch == '█' {
90                let filled = col < fill;
91                let glyph = if filled || done { "█" } else { "░" };
92                let style = if !color {
93                    Style::default()
94                } else if filled || done {
95                    Style::default().fg(GOLD).add_modifier(Modifier::BOLD)
96                } else {
97                    Style::default().fg(SEAM)
98                };
99                spans.push(Span::styled(glyph.to_string(), style));
100            } else {
101                spans.push(Span::raw(" "));
102            }
103        }
104        lines.push(Line::from(spans));
105    }
106
107    // Tagline fades in once the gold is past halfway; the hint comes at the end.
108    lines.push(Line::from(""));
109    if fill * 2 >= width {
110        lines.push(Line::from(Span::styled(TAGLINE, dim_if(color))));
111    } else {
112        lines.push(Line::from(""));
113    }
114    lines.push(Line::from(""));
115    if done {
116        lines.push(Line::from(Span::styled("press any key", dim_if(color))));
117    } else {
118        lines.push(Line::from(""));
119    }
120
121    let content_height = lines.len() as u16;
122    f.render_widget(
123        Paragraph::new(lines).alignment(Alignment::Center),
124        centered(area, content_height),
125    );
126}
127
128/// The brand mark: a 5-row tile crossed by a golden kintsugi seam. The seam
129/// glyphs are gold (when color is on); the frame is dim. Returns centered Lines.
130fn mark_lines(color: bool) -> Vec<Line<'static>> {
131    let frame_style = if color {
132        Style::default().fg(SEAM)
133    } else {
134        Style::default()
135    };
136    let gold = if color {
137        Style::default().fg(GOLD).add_modifier(Modifier::BOLD)
138    } else {
139        Style::default()
140    };
141    // Each tile row: a bordered cell with a seam segment (the ╲ ╳ ╱ run) in gold.
142    let row = |left: &'static str, seam: &'static str, right: &'static str| {
143        Line::from(vec![
144            Span::styled(left, frame_style),
145            Span::styled(seam, gold),
146            Span::styled(right, frame_style),
147        ])
148    };
149    // Inner content between the borders is always 7 columns wide, so the tile
150    // stays a perfect rectangle.
151    vec![
152        Line::from(Span::styled("╭───────╮", frame_style)),
153        row("│  ", "╲", "    │"),
154        row("│  ", "╲ ╱", "  │"),
155        row("│   ", "╳", "   │"),
156        row("│  ", "╱ ╲", "  │"),
157        row("│  ", "╱", "    │"),
158        Line::from(Span::styled("╰───────╯", frame_style)),
159    ]
160}
161
162/// A vertically-centered sub-rect of `area` that is `height` rows tall.
163fn centered(area: Rect, height: u16) -> Rect {
164    let h = height.min(area.height);
165    let y = area.y + (area.height.saturating_sub(h)) / 2;
166    Rect {
167        x: area.x,
168        y,
169        width: area.width,
170        height: h,
171    }
172}
173
174fn dim_if(color: bool) -> Style {
175    style_if(color, Style::default().add_modifier(Modifier::DIM))
176}
177
178fn style_if(color: bool, s: Style) -> Style {
179    if color {
180        s
181    } else {
182        Style::default()
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use ratatui::backend::TestBackend;
190    use ratatui::Terminal;
191
192    fn frame_text(frame: usize, color: bool, w: u16, h: u16) -> String {
193        let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
194        term.draw(|f| render(f, f.area(), frame, color)).unwrap();
195        let buf = term.backend().buffer().clone();
196        buf.content().iter().map(|c| c.symbol()).collect()
197    }
198
199    #[test]
200    fn wordmark_rows_are_aligned() {
201        let rows = wordmark_rows();
202        let w = rows[0].chars().count();
203        assert!(rows.iter().all(|r| r.chars().count() == w), "rows ragged");
204        assert!(w >= 30, "banner should be a real width");
205    }
206
207    #[test]
208    fn early_frame_shows_unfilled_seam_glyph() {
209        // At frame 0 nothing is filled yet → the seam glyph '░' is present.
210        let text = frame_text(0, true, 80, 24);
211        assert!(text.contains('░'), "expected unfilled seam at frame 0");
212    }
213
214    #[test]
215    fn final_frame_is_fully_filled_and_prompts() {
216        let text = frame_text(FRAMES, true, 80, 24);
217        assert!(text.contains('█'), "expected filled blocks at the end");
218        assert!(!text.contains('░'), "nothing should remain unfilled");
219        assert!(text.contains("press any key"));
220        assert!(text.contains("safety layer"));
221    }
222
223    #[test]
224    fn mono_animation_still_sweeps_without_color() {
225        // Without color the motion is carried by the glyph swap, not the palette.
226        let early = frame_text(0, false, 80, 24);
227        let late = frame_text(FRAMES, false, 80, 24);
228        assert!(early.contains('░'));
229        assert!(!late.contains('░'));
230    }
231
232    #[test]
233    fn narrow_terminal_degrades_without_panic() {
234        let text = frame_text(10, true, 24, 10);
235        assert!(text.contains("KINTSUGI"));
236    }
237
238    #[test]
239    fn brand_mark_is_a_perfect_rectangle() {
240        // Every tile row must be the same display width or the box looks broken.
241        let lines = mark_lines(false);
242        let widths: Vec<usize> = lines
243            .iter()
244            .map(|l| l.spans.iter().map(|s| s.content.chars().count()).sum())
245            .collect();
246        assert!(
247            widths.windows(2).all(|w| w[0] == w[1]),
248            "tile rows ragged: {widths:?}"
249        );
250    }
251}