1const 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
66const 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
113const 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
160const 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
208const 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
255const 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
302const 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
350fn splash_pixel(key: char) -> Option<(u8, u8, u8)> {
353 Some(match key {
354 '#' => (28, 107, 46), 'G' => (76, 192, 106), 'L' => (139, 224, 156), 'F' => (255, 208, 36), 'P' => (184, 115, 46), 'T' => (107, 67, 33), _ => return None,
361 })
362}
363
364pub const SPLASH_SCENE_ROWS: usize = SPLASH_FRAME_0.len();
367
368pub const SPLASH_SCENE_COLS: usize = 20;
373
374pub 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 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 FRAMES.len() - 1
420 };
421 let scene = FRAMES[scene_idx];
422 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
434pub fn splash_shows_scene(w: u16, h: u16) -> bool {
439 w >= 48 && h >= 30
440}
441
442const SPLASH_SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
445
446pub 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}"; 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 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 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
590pub(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 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 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 const TICKS_PER_FRAME: u64 = 6;
679 const FRAME_COUNT: u64 = 14;
682 const FINAL_FRAME_HOLD_TICKS: u64 = 67;
684
685 #[test]
686 fn splash_shows_scene_gates_on_terminal_size() {
687 assert!(splash_shows_scene(120, 50));
689 assert!(splash_shows_scene(48, 30)); 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 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 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 let line_w = |l: &ratatui::text::Line| -> usize {
740 l.spans.iter().map(|s| s.content.chars().count()).sum()
741 };
742 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 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 let f0 = splash_scene_lines(0);
774 assert_eq!(f0.len(), SPLASH_FRAME_0.len());
775 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 let cycle_ticks = FRAME_COUNT * TICKS_PER_FRAME + FINAL_FRAME_HOLD_TICKS;
786 assert_eq!(render(&splash_scene_lines(cycle_ticks)), r0);
787 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 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 assert_eq!(render(&splash_scene_lines(grow_ticks)), bud_frame);
814 assert_eq!(
816 render(&splash_scene_lines(grow_ticks + FINAL_FRAME_HOLD_TICKS / 2)),
817 bud_frame
818 );
819 assert_eq!(
821 render(&splash_scene_lines(grow_ticks + FINAL_FRAME_HOLD_TICKS - 1)),
822 bud_frame
823 );
824 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 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 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}