Skip to main content

ebman/
splash.rs

1//! Pixel-art splash scene — a beanstalk growing out of its pot
2//! (the `ebman` = Elastic *Beanstalk* gag: watch the thing sprout).
3//! Used by the boot splash and the `:about` overlay.
4//!
5//! Each glyph in a frame is a palette key, not a literal. The
6//! renderer ([`splash_scene_lines`]) paints every non-`.` key as a
7//! **two-cell** `██` block coloured via [`splash_pixel`] — two cells
8//! wide so each logical pixel is roughly square (terminal cells are
9//! ~1:2). The `.` key is transparent (rendered as two blank cells).
10//!
11//! 14 frames take the env from bare pot to full bloom: 8 keyframes
12//! from the JSON design source (indices 0, 1, 3, 5, 7, 9, 11, 13)
13//! interleaved with 6 hand-drawn in-betweens (2, 4, 6, 8, 10, 12)
14//! that smooth the largest visual jumps. The pot stays rooted across
15//! every frame so the growing motion reads against a fixed anchor.
16//!
17//! Palette keys: `#` outline (dark green) · `G` leaf · `L` leaf
18//! highlight · `F` bud · `P` pot · `T` soil. All frames are 20×20.
19
20const SPLASH_FRAME_0: &[&str] = &[
21    "....................",
22    "....................",
23    "....................",
24    "....................",
25    "....................",
26    "....................",
27    "....................",
28    "....................",
29    "....................",
30    "....................",
31    "....................",
32    "....................",
33    "....................",
34    "....................",
35    "....................",
36    "....................",
37    "....############....",
38    "....#TTTTTTTTTT#....",
39    "....#PPPPPPPPPP#....",
40    ".....##########.....",
41];
42
43const SPLASH_FRAME_1: &[&str] = &[
44    "....................",
45    "....................",
46    "....................",
47    "....................",
48    "....................",
49    "....................",
50    "....................",
51    "....................",
52    "....................",
53    "....................",
54    "....................",
55    "....................",
56    "....................",
57    "....................",
58    "....................",
59    ".........G..........",
60    "....############....",
61    "....#TTTTTTTTTT#....",
62    "....#PPPPPPPPPP#....",
63    ".....##########.....",
64];
65
66// In-between: sprout extends to a 2-pixel stem before the wings appear.
67const SPLASH_FRAME_2: &[&str] = &[
68    "....................",
69    "....................",
70    "....................",
71    "....................",
72    "....................",
73    "....................",
74    "....................",
75    "....................",
76    "....................",
77    "....................",
78    "....................",
79    "....................",
80    "....................",
81    "....................",
82    ".........G..........",
83    ".........#..........",
84    "....############....",
85    "....#TTTTTTTTTT#....",
86    "....#PPPPPPPPPP#....",
87    ".....##########.....",
88];
89
90const SPLASH_FRAME_3: &[&str] = &[
91    "....................",
92    "....................",
93    "....................",
94    "....................",
95    "....................",
96    "....................",
97    "....................",
98    "....................",
99    "....................",
100    "....................",
101    "....................",
102    "....................",
103    "....................",
104    "........G#G.........",
105    ".........#..........",
106    ".........#..........",
107    "....############....",
108    "....#TTTTTTTTTT#....",
109    "....#PPPPPPPPPP#....",
110    ".....##########.....",
111];
112
113// In-between: wings shed, stem grows another two rows.
114const SPLASH_FRAME_4: &[&str] = &[
115    "....................",
116    "....................",
117    "....................",
118    "....................",
119    "....................",
120    "....................",
121    "....................",
122    "....................",
123    "....................",
124    "....................",
125    "....................",
126    "....................",
127    ".........G..........",
128    ".........#..........",
129    ".........#..........",
130    ".........#..........",
131    "....############....",
132    "....#TTTTTTTTTT#....",
133    "....#PPPPPPPPPP#....",
134    ".....##########.....",
135];
136
137const SPLASH_FRAME_5: &[&str] = &[
138    "....................",
139    "....................",
140    "....................",
141    "....................",
142    "....................",
143    "....................",
144    "....................",
145    "....................",
146    "....................",
147    "....................",
148    ".........G..........",
149    ".........#..........",
150    ".........#..........",
151    ".........#..........",
152    ".........#..........",
153    ".........#..........",
154    "....############....",
155    "....#TTTTTTTTTT#....",
156    "....#PPPPPPPPPP#....",
157    ".....##########.....",
158];
159
160// In-between: small leaf bud forming at the top of the stem before
161// the full cluster fills in.
162const SPLASH_FRAME_6: &[&str] = &[
163    "....................",
164    "....................",
165    "....................",
166    "....................",
167    "....................",
168    "....................",
169    "....................",
170    "....................",
171    "....................",
172    "....................",
173    "........###.........",
174    ".......#GGG#........",
175    "........###.........",
176    ".........#..........",
177    ".........#..........",
178    ".........#..........",
179    "....############....",
180    "....#TTTTTTTTTT#....",
181    "....#PPPPPPPPPP#....",
182    ".....##########.....",
183];
184
185const SPLASH_FRAME_7: &[&str] = &[
186    "....................",
187    "....................",
188    "....................",
189    "....................",
190    "....................",
191    "....................",
192    "....................",
193    "....................",
194    "....................",
195    "....................",
196    ".......#######......",
197    "......#GGGGGGG#.....",
198    ".......#GGLGG#......",
199    "........#####.......",
200    ".........#..........",
201    ".........#..........",
202    "....############....",
203    "....#TTTTTTTTTT#....",
204    "....#PPPPPPPPPP#....",
205    ".....##########.....",
206];
207
208// In-between: upper sprout begins to climb above the lower cluster.
209const SPLASH_FRAME_8: &[&str] = &[
210    "....................",
211    "....................",
212    "....................",
213    "....................",
214    "....................",
215    "....................",
216    "....................",
217    "....................",
218    ".........G..........",
219    ".........#..........",
220    ".......#######......",
221    "......#GGGGGGG#.....",
222    ".......#GGLGG#......",
223    "........#####.......",
224    ".........#..........",
225    ".........#..........",
226    "....############....",
227    "....#TTTTTTTTTT#....",
228    "....#PPPPPPPPPP#....",
229    ".....##########.....",
230];
231
232const SPLASH_FRAME_9: &[&str] = &[
233    "....................",
234    "....................",
235    "....................",
236    "....................",
237    ".........G..........",
238    ".........#..........",
239    ".........#..........",
240    ".........#..........",
241    ".........#..........",
242    ".........#..........",
243    ".......#######......",
244    "......#GGGGGGG#.....",
245    ".......#GGLGG#......",
246    "........#####.......",
247    ".........#..........",
248    ".........#..........",
249    "....############....",
250    "....#TTTTTTTTTT#....",
251    "....#PPPPPPPPPP#....",
252    ".....##########.....",
253];
254
255// In-between: small upper bud forms before the branched upper cluster.
256const SPLASH_FRAME_10: &[&str] = &[
257    "....................",
258    "....................",
259    "....................",
260    "....................",
261    ".........G..........",
262    "........###.........",
263    ".......#GGG#........",
264    "........###.........",
265    ".........#..........",
266    ".........#..........",
267    ".......#######......",
268    "......#GGGGGGG#.....",
269    ".......#GGLGG#......",
270    "........#####.......",
271    ".........#..........",
272    ".........#..........",
273    "....############....",
274    "....#TTTTTTTTTT#....",
275    "....#PPPPPPPPPP#....",
276    ".....##########.....",
277];
278
279const SPLASH_FRAME_11: &[&str] = &[
280    "....................",
281    "....................",
282    "....................",
283    "....................",
284    ".........G..........",
285    "......##.#.##.......",
286    ".....#GG#G#GG#......",
287    ".....#GLGGGLG#......",
288    "......##.#.##.......",
289    ".........#..........",
290    ".......#######......",
291    "......#GGGGGGG#.....",
292    ".......#GGLGG#......",
293    "........#####.......",
294    ".........#..........",
295    ".........#..........",
296    "....############....",
297    "....#TTTTTTTTTT#....",
298    "....#PPPPPPPPPP#....",
299    ".....##########.....",
300];
301
302// In-between: stem extends one row above the upper cluster before
303// the bud forms its cap.
304const SPLASH_FRAME_12: &[&str] = &[
305    "....................",
306    "....................",
307    "....................",
308    ".........G..........",
309    ".........#..........",
310    "......##.#.##.......",
311    ".....#GG#G#GG#......",
312    ".....#GLGGGLG#......",
313    "......##.#.##.......",
314    ".........#..........",
315    ".......#######......",
316    "......#GGGGGGG#.....",
317    ".......#GGLGG#......",
318    "........#####.......",
319    ".........#..........",
320    ".........#..........",
321    "....############....",
322    "....#TTTTTTTTTT#....",
323    "....#PPPPPPPPPP#....",
324    ".....##########.....",
325];
326
327const SPLASH_FRAME_13: &[&str] = &[
328    "....................",
329    ".........##.........",
330    "........#FF#........",
331    ".........##.........",
332    ".........#..........",
333    "......##.#.##.......",
334    ".....#GG#G#GG#......",
335    ".....#GLGGGLG#......",
336    "......##.#.##.......",
337    ".........#..........",
338    ".......#######......",
339    "......#GGGGGGG#.....",
340    ".......#GGLGG#......",
341    "........#####.......",
342    ".........#..........",
343    ".........#..........",
344    "....############....",
345    "....#TTTTTTTTTT#....",
346    "....#PPPPPPPPPP#....",
347    ".....##########.....",
348];
349
350/// Map a pixel-art palette key to an RGB colour. `None` = a
351/// transparent cell (rendered as two blank cells).
352fn splash_pixel(key: char) -> Option<(u8, u8, u8)> {
353    Some(match key {
354        '#' => (28, 107, 46),   // outline (dark green)
355        'G' => (76, 192, 106),  // leaf body
356        'L' => (139, 224, 156), // leaf highlight
357        'F' => (255, 208, 36),  // bud (yellow)
358        'P' => (184, 115, 46),  // pot (brown)
359        'T' => (107, 67, 33),   // soil (dark brown)
360        _ => return None,
361    })
362}
363
364/// Number of art rows in the splash scene (every frame is the same
365/// height). Used by callers to size the splash card / about popup.
366pub const SPLASH_SCENE_ROWS: usize = SPLASH_FRAME_0.len();
367
368/// Number of art columns per frame. Every frame is exactly this
369/// wide; the renderer relies on that to avoid per-frame jitter when
370/// alignment recentres. Doubled (×2) at render time so logical
371/// pixels are roughly square in the terminal cell grid.
372pub const SPLASH_SCENE_COLS: usize = 20;
373
374/// Build the coloured lines for the splash scene at `frame`. Each
375/// non-transparent pixel is a **two-cell** `██` block in its palette
376/// colour, so the logical pixels are roughly square. Used by the
377/// boot splash and the `:about` overlay.
378///
379/// 14 frames advance at ≈180 ms each (6 30 ms ticks); the grow phase
380/// (empty pot → bud) takes ~2.5 s so it fits inside the boot splash's
381/// 3 s minimum duration. After the grow phase finishes the final
382/// bud-blossom frame lingers for an extra ~2 s
383/// (`FINAL_FRAME_HOLD_TICKS`) before wrapping back to the empty pot
384/// — gives the bloom time to land visually in the looping `:about`
385/// view rather than snapping straight back to the empty pot.
386pub fn splash_scene_lines(frame: u64) -> Vec<ratatui::text::Line<'static>> {
387    use ratatui::style::Color;
388    const FRAMES: [&[&str]; 14] = [
389        SPLASH_FRAME_0,
390        SPLASH_FRAME_1,
391        SPLASH_FRAME_2,
392        SPLASH_FRAME_3,
393        SPLASH_FRAME_4,
394        SPLASH_FRAME_5,
395        SPLASH_FRAME_6,
396        SPLASH_FRAME_7,
397        SPLASH_FRAME_8,
398        SPLASH_FRAME_9,
399        SPLASH_FRAME_10,
400        SPLASH_FRAME_11,
401        SPLASH_FRAME_12,
402        SPLASH_FRAME_13,
403    ];
404    // 14 frames × 6 ticks × 30 ms = 2520 ms for the grow phase, well
405    // inside the 3 s SPLASH_MIN_DURATION (so the boot splash always
406    // lands on the bud frame). Then 67 ticks × 30 ms ≈ 2 s of hold on
407    // the last frame before the cycle wraps — only visible in the
408    // looping `:about` view since the boot splash dismisses during
409    // the hold anyway. Total cycle: 4530 ms.
410    const TICKS_PER_FRAME: usize = 6;
411    const FINAL_FRAME_HOLD_TICKS: usize = 67;
412    let grow_ticks = FRAMES.len() * TICKS_PER_FRAME;
413    let cycle_ticks = grow_ticks + FINAL_FRAME_HOLD_TICKS;
414    let tick = (frame as usize) % cycle_ticks;
415    let scene_idx = if tick < grow_ticks {
416        tick / TICKS_PER_FRAME
417    } else {
418        // Hold phase: stay on the final bloom frame.
419        FRAMES.len() - 1
420    };
421    let scene = FRAMES[scene_idx];
422    // The pixel→`██` rendering loop lives in tui-common::splash so
423    // pgman (and any future sibling) shares the same machinery. We
424    // wrap our local 6-key palette in a closure that hands back a
425    // ratatui Color so the generic renderer doesn't have to know
426    // about RGB tuples.
427    tui_common::splash::render_frame(
428        scene,
429        |key| splash_pixel(key).map(|(r, g, b)| Color::Rgb(r, g, b)),
430        SPLASH_SCENE_COLS,
431    )
432}
433
434/// Pure: whether the boot splash has room for the pixel-art scene.
435/// Below this it falls back to the compact text-only card. Scene is
436/// 40 cells wide (20 px × 2) and 20 rows tall; the threshold is the
437/// card chrome budget (+ borders / padding) on top.
438pub fn splash_shows_scene(w: u16, h: u16) -> bool {
439    w >= 48 && h >= 30
440}
441
442/// Spinner animation frames used by the boot-time splash. Each frame
443/// advances every ~3 frames in [`draw_splash`].
444const SPLASH_SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
445
446/// Draw one splash frame to the terminal. Composes the
447/// [`splash_scene_lines`] beanstalk-growth scene (when the terminal
448/// has room) with the tagline + byline + connecting-to-AWS spinner.
449/// In Powerline mode (resolved by `font_probe` before this runs)
450/// pills the tagline + byline + a `v{VERSION}` tab on the top
451/// border; falls back to plain text otherwise.
452///
453/// Frame counter drives both the spinner cycle and the one-shot
454/// border-glow easing (cyan → magenta → cyan over ~1s). Called from
455/// `main()`'s splash loop until the initial AWS context lands.
456pub fn draw_splash(
457    terminal: &mut crate::Tui,
458    frame: u64,
459    icons: &str,
460) -> color_eyre::eyre::Result<()> {
461    use ratatui::layout::{Alignment, Constraint, Direction, Layout};
462    use ratatui::style::{Color, Modifier, Style};
463    use ratatui::text::{Line, Span};
464    use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
465
466    terminal.draw(|f| {
467        let area = f.area();
468        let powerline = icons == "powerline";
469        let show_scene = splash_shows_scene(area.width, area.height);
470        let (card_w, card_h): (u16, u16) = if show_scene { (46, 30) } else { (52, 9) };
471        let v = Layout::default()
472            .direction(Direction::Vertical)
473            .constraints([
474                Constraint::Min(0),
475                Constraint::Length(card_h),
476                Constraint::Min(0),
477            ])
478            .split(area);
479        let h = Layout::default()
480            .direction(Direction::Horizontal)
481            .constraints([
482                Constraint::Min(0),
483                Constraint::Length(card_w),
484                Constraint::Min(0),
485            ])
486            .split(v[1]);
487
488        let mut lines: Vec<Line> = Vec::new();
489        lines.push(Line::from(""));
490        if show_scene {
491            lines.extend(splash_scene_lines(frame));
492            lines.push(Line::from(""));
493        }
494        if powerline {
495            let tag_bg = Color::Rgb(35, 45, 60);
496            let tag_fg = Color::Rgb(170, 180, 200);
497            let cloud = "\u{f0c2}"; // fa-cloud, stable across Nerd Font releases
498            lines.push(
499                Line::from(vec![
500                    Span::styled("\u{e0b6}", Style::default().fg(tag_bg)),
501                    Span::styled(
502                        format!(" {cloud}  k9s-style TUI for AWS Elastic Beanstalk "),
503                        Style::default().fg(tag_fg).bg(tag_bg),
504                    ),
505                    Span::styled("\u{e0b4}", Style::default().fg(tag_bg)),
506                ])
507                .alignment(Alignment::Center),
508            );
509            let by_bg = Color::Rgb(50, 40, 75);
510            let by_fg = Color::Rgb(220, 195, 245);
511            lines.push(
512                Line::from(vec![
513                    Span::styled("\u{e0b6}", Style::default().fg(by_bg)),
514                    Span::styled(
515                        " by Tom Baldwin · Polymorphism Ltd ",
516                        Style::default().fg(by_fg).bg(by_bg),
517                    ),
518                    Span::styled("\u{e0b4}", Style::default().fg(by_bg)),
519                ])
520                .alignment(Alignment::Center),
521            );
522        } else {
523            lines.push(
524                Line::from(Span::styled(
525                    "k9s-style TUI for AWS Elastic Beanstalk",
526                    Style::default().fg(Color::Rgb(150, 155, 170)),
527                ))
528                .alignment(Alignment::Center),
529            );
530            lines.push(
531                Line::from(Span::styled(
532                    "by Tom Baldwin · Polymorphism Ltd",
533                    Style::default().fg(Color::Rgb(180, 140, 230)),
534                ))
535                .alignment(Alignment::Center),
536            );
537        }
538        lines.push(Line::from(""));
539        // Spinner: advance every 3 frames → ~10 fps spin at 30 ms ticks.
540        // Dots: advance every 8 frames → ~240 ms per dot.
541        let spinner = SPLASH_SPINNER[(frame as usize / 3) % SPLASH_SPINNER.len()];
542        let dots = ".".repeat((frame as usize / 8) % 4);
543        lines.push(
544            Line::from(Span::styled(
545                format!("{spinner} connecting to AWS{dots}"),
546                Style::default()
547                    .fg(Color::Rgb(255, 200, 120))
548                    .add_modifier(Modifier::BOLD),
549            ))
550            .alignment(Alignment::Center),
551        );
552
553        // One-shot border glow: cyan → magenta → cyan over the first
554        // ~1 s of splash time, then settles to cyan.
555        const BORDER_SPOTLIGHT_FRAMES: f64 = 33.0;
556        let border_phase = (frame as f64 / BORDER_SPOTLIGHT_FRAMES).clamp(0.0, 1.0);
557        let border_glow = if border_phase < 0.5 {
558            border_phase * 2.0
559        } else {
560            (1.0 - border_phase) * 2.0
561        };
562        let border_hue = 180.0 + border_glow * 120.0;
563        let (br, bg, bb) = hsl_to_rgb(border_hue, 0.60, 0.65);
564        let mut block = Block::default()
565            .borders(Borders::ALL)
566            .border_type(BorderType::Rounded)
567            .border_style(Style::default().fg(Color::Rgb(br, bg, bb)));
568        if powerline {
569            let tab_bg = Color::Rgb(60, 50, 80);
570            let tab_fg = Color::Rgb(220, 200, 250);
571            let version = format!(" v{} ", env!("CARGO_PKG_VERSION"));
572            let title = Line::from(vec![
573                Span::styled("\u{e0b6}", Style::default().fg(tab_bg)),
574                Span::styled(
575                    version,
576                    Style::default()
577                        .fg(tab_fg)
578                        .bg(tab_bg)
579                        .add_modifier(Modifier::BOLD),
580                ),
581                Span::styled("\u{e0b4}", Style::default().fg(tab_bg)),
582            ]);
583            block = block.title(title).title_alignment(Alignment::Center);
584        }
585        f.render_widget(Paragraph::new(lines).block(block), h[1]);
586    })?;
587    Ok(())
588}
589
590/// Standard HSL → RGB. `h` in degrees 0-360, `s` and `l` in 0.0-1.0.
591/// Used by [`draw_splash`] for the boot border-glow easing. Crate-
592/// internal — no external callers.
593pub(crate) fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
594    let h = h.rem_euclid(360.0);
595    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
596    let h_prime = h / 60.0;
597    let x = c * (1.0 - (h_prime.rem_euclid(2.0) - 1.0).abs());
598    let (r1, g1, b1) = if h_prime < 1.0 {
599        (c, x, 0.0)
600    } else if h_prime < 2.0 {
601        (x, c, 0.0)
602    } else if h_prime < 3.0 {
603        (0.0, c, x)
604    } else if h_prime < 4.0 {
605        (0.0, x, c)
606    } else if h_prime < 5.0 {
607        (x, 0.0, c)
608    } else {
609        (c, 0.0, x)
610    };
611    let m = l - c / 2.0;
612    let to_u8 = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
613    (to_u8(r1), to_u8(g1), to_u8(b1))
614}
615
616#[cfg(test)]
617mod tests {
618    use super::{
619        hsl_to_rgb, splash_pixel, splash_scene_lines, splash_shows_scene, SPLASH_FRAME_0,
620        SPLASH_FRAME_1, SPLASH_FRAME_10, SPLASH_FRAME_11, SPLASH_FRAME_12, SPLASH_FRAME_13,
621        SPLASH_FRAME_2, SPLASH_FRAME_3, SPLASH_FRAME_4, SPLASH_FRAME_5, SPLASH_FRAME_6,
622        SPLASH_FRAME_7, SPLASH_FRAME_8, SPLASH_FRAME_9,
623    };
624
625    #[test]
626    fn hsl_to_rgb_red() {
627        let (r, g, b) = hsl_to_rgb(0.0, 1.0, 0.5);
628        assert_eq!((r, g, b), (255, 0, 0));
629    }
630
631    #[test]
632    fn hsl_to_rgb_cyan_and_magenta() {
633        let (r, g, b) = hsl_to_rgb(180.0, 1.0, 0.5);
634        assert_eq!((r, g, b), (0, 255, 255));
635        let (r, g, b) = hsl_to_rgb(300.0, 1.0, 0.5);
636        assert_eq!((r, g, b), (255, 0, 255));
637    }
638
639    #[test]
640    fn hsl_to_rgb_clamps_to_valid_range() {
641        // u8 enforces 0..=255 by type, so additionally assert that
642        // moderate-saturation mid-lightness inputs produce visible
643        // (non-collapsed) outputs across the wheel, and that hue is
644        // wrapped modulo 360 (h=-30 should equal h=330).
645        for h in [-30.0, 0.0, 90.0, 180.0, 270.0, 360.0, 720.0] {
646            let (r, g, b) = hsl_to_rgb(h, 0.7, 0.65);
647            let max = r.max(g).max(b);
648            let min = r.min(g).min(b);
649            assert!(max > min, "hue {h} collapsed to greyscale");
650        }
651        assert_eq!(hsl_to_rgb(-30.0, 0.7, 0.65), hsl_to_rgb(330.0, 0.7, 0.65));
652        assert_eq!(hsl_to_rgb(0.0, 0.7, 0.65), hsl_to_rgb(360.0, 0.7, 0.65));
653        // Zero saturation collapses to greyscale at lightness * 255.
654        let (r, g, b) = hsl_to_rgb(123.0, 0.0, 0.5);
655        assert_eq!(r, g);
656        assert_eq!(g, b);
657    }
658
659    const ALL_SPLASH_FRAMES: [&[&str]; 14] = [
660        SPLASH_FRAME_0,
661        SPLASH_FRAME_1,
662        SPLASH_FRAME_2,
663        SPLASH_FRAME_3,
664        SPLASH_FRAME_4,
665        SPLASH_FRAME_5,
666        SPLASH_FRAME_6,
667        SPLASH_FRAME_7,
668        SPLASH_FRAME_8,
669        SPLASH_FRAME_9,
670        SPLASH_FRAME_10,
671        SPLASH_FRAME_11,
672        SPLASH_FRAME_12,
673        SPLASH_FRAME_13,
674    ];
675
676    /// Mirrors `splash_scene_lines`'s internal constants so the cycle
677    /// probes match the actual frame stride / hold duration.
678    const TICKS_PER_FRAME: u64 = 6;
679    /// Total number of frames in the animation cycle. Mirrors the
680    /// FRAMES array length in `splash_scene_lines`.
681    const FRAME_COUNT: u64 = 14;
682    /// Hold-on-last-frame tick count. Mirrors `FINAL_FRAME_HOLD_TICKS`.
683    const FINAL_FRAME_HOLD_TICKS: u64 = 67;
684
685    #[test]
686    fn splash_shows_scene_gates_on_terminal_size() {
687        // Roomy → scene.
688        assert!(splash_shows_scene(120, 50));
689        assert!(splash_shows_scene(48, 30)); // exact threshold
690                                             // Too narrow / too short → text-only fallback.
691        assert!(!splash_shows_scene(47, 50));
692        assert!(!splash_shows_scene(120, 29));
693        assert!(!splash_shows_scene(40, 20));
694    }
695
696    #[test]
697    fn splash_pixel_maps_keys_and_treats_dot_as_transparent() {
698        // Every key used in the art frames must resolve to a colour
699        // (apart from `.`, which is transparent by design).
700        for frame in ALL_SPLASH_FRAMES {
701            for row in frame {
702                for ch in row.chars() {
703                    if ch != '.' {
704                        assert!(
705                            splash_pixel(ch).is_some(),
706                            "art key '{ch}' has no palette entry"
707                        );
708                    }
709                }
710            }
711        }
712        assert_eq!(splash_pixel('.'), None);
713    }
714
715    #[test]
716    fn all_splash_frames_have_identical_dimensions() {
717        // Frames must agree on row count *and* column count — the
718        // pot anchor is meant to stay rooted while only the plant
719        // above it changes, so any frame-to-frame size shift would
720        // re-centre and read as a jitter.
721        let h = SPLASH_FRAME_0.len();
722        for f in ALL_SPLASH_FRAMES {
723            assert_eq!(f.len(), h, "frame height mismatch");
724            for row in f {
725                assert_eq!(
726                    row.chars().count(),
727                    super::SPLASH_SCENE_COLS,
728                    "row width mismatch in frame: {row:?}"
729                );
730            }
731        }
732    }
733
734    #[test]
735    fn splash_scene_frames_render_identical_width() {
736        // Every frame must render to the same total cell width, or
737        // centre-alignment shifts the sprite sideways between frames
738        // (a visible jitter).
739        let line_w = |l: &ratatui::text::Line| -> usize {
740            l.spans.iter().map(|s| s.content.chars().count()).sum()
741        };
742        // Probe one frame from each cycle slot; the sampled tick
743        // offsets span the full N-frame cycle.
744        let widths: Vec<usize> = (0..FRAME_COUNT)
745            .map(|i| {
746                let ls = splash_scene_lines(i * TICKS_PER_FRAME);
747                line_w(&ls[0])
748            })
749            .collect();
750        assert!(
751            widths.iter().all(|&w| w == widths[0]),
752            "frames render at differing widths: {widths:?}"
753        );
754    }
755
756    #[test]
757    fn splash_scene_lines_renders_full_scene_and_animates() {
758        // Include the foreground colour as well as the glyph — the
759        // animation is mostly composition (more pixels appear as the
760        // beanstalk grows), so a glyph-only compare may miss subtle
761        // recolours; a (color, glyph) compare catches both.
762        let render = |ls: &[ratatui::text::Line]| {
763            ls.iter()
764                .map(|l| {
765                    l.spans
766                        .iter()
767                        .map(|s| format!("{:?}{}", s.style.fg, s.content))
768                        .collect::<String>()
769                })
770                .collect::<Vec<_>>()
771        };
772        // Every frame renders every art row.
773        let f0 = splash_scene_lines(0);
774        assert_eq!(f0.len(), SPLASH_FRAME_0.len());
775        // Probe several cycle slots — at least one pair must differ.
776        let r0 = render(&f0);
777        let r5 = render(&splash_scene_lines(5 * TICKS_PER_FRAME));
778        let r13 = render(&splash_scene_lines(13 * TICKS_PER_FRAME));
779        assert!(
780            r0 != r5 || r0 != r13,
781            "frames should differ — scene is not animating"
782        );
783        // The full cycle wraps back to frame 0 after the grow phase
784        // PLUS the hold-on-last-frame tail.
785        let cycle_ticks = FRAME_COUNT * TICKS_PER_FRAME + FINAL_FRAME_HOLD_TICKS;
786        assert_eq!(render(&splash_scene_lines(cycle_ticks)), r0);
787        // Square pixels: a painted cell is the two-block "██" (sampled
788        // from the last frame which has the most pixels lit).
789        assert!(splash_scene_lines(13 * TICKS_PER_FRAME)
790            .iter()
791            .flat_map(|l| l.spans.iter())
792            .any(|s| s.content.as_ref() == "██"));
793    }
794
795    #[test]
796    fn last_frame_holds_for_two_seconds_before_wrapping() {
797        // Pin the hold behaviour: every tick inside the hold window
798        // renders the bud-blossom frame, but the very next tick after
799        // the hold ends wraps back to the empty-pot frame.
800        let render = |ls: &[ratatui::text::Line]| {
801            ls.iter()
802                .map(|l| {
803                    l.spans
804                        .iter()
805                        .map(|s| format!("{:?}{}", s.style.fg, s.content))
806                        .collect::<String>()
807                })
808                .collect::<Vec<_>>()
809        };
810        let grow_ticks = FRAME_COUNT * TICKS_PER_FRAME;
811        let bud_frame = render(&splash_scene_lines(grow_ticks - 1));
812        // First tick of the hold window — still bud.
813        assert_eq!(render(&splash_scene_lines(grow_ticks)), bud_frame);
814        // Middle of the hold window — still bud.
815        assert_eq!(
816            render(&splash_scene_lines(grow_ticks + FINAL_FRAME_HOLD_TICKS / 2)),
817            bud_frame
818        );
819        // Last tick of the hold window — still bud.
820        assert_eq!(
821            render(&splash_scene_lines(grow_ticks + FINAL_FRAME_HOLD_TICKS - 1)),
822            bud_frame
823        );
824        // One tick past the hold window — wrapped back to frame 0.
825        let pot_frame = render(&splash_scene_lines(0));
826        assert_eq!(
827            render(&splash_scene_lines(grow_ticks + FINAL_FRAME_HOLD_TICKS)),
828            pot_frame
829        );
830    }
831
832    #[test]
833    fn last_frame_hold_is_approximately_two_seconds() {
834        // Pin the design intent: the hold is ~2 s in wall-clock time
835        // (animation tick = 30 ms). If a future bump knocks the hold
836        // below 1.5 s or above 2.5 s, that's almost certainly a bug.
837        let hold_ms = FINAL_FRAME_HOLD_TICKS * 30;
838        assert!(
839            (1500..=2500).contains(&hold_ms),
840            "hold is {hold_ms} ms — should sit near 2 s"
841        );
842    }
843
844    #[test]
845    fn splash_grow_phase_completes_within_min_duration() {
846        // Pin the speed-up: the GROW phase (empty pot → bud) should
847        // finish in less than the splash's 3 s minimum duration so
848        // the boot splash always lands on the final-bloom frame
849        // before the table replaces it. The hold-on-last-frame tail
850        // continues past 3 s but only the looping `:about` view ever
851        // sees it — the boot splash dismisses during the hold.
852        let grow_ms = FRAME_COUNT * TICKS_PER_FRAME * 30;
853        assert!(
854            grow_ms < 3000,
855            "grow phase is {grow_ms} ms — exceeds 3 s splash duration; bump TICKS_PER_FRAME down"
856        );
857    }
858}