Skip to main content

cinder/tui/
splash.rs

1//! Animated fire-colored "CINDER" splash shown while initial network setup is
2//! in flight. Owns the terminal for the duration of the animation and hands it
3//! back so the TUI can take over without leaving/re-entering the alt screen.
4
5use std::io::Stdout;
6use std::time::{Duration, Instant};
7
8use chrono::Utc;
9use ratatui::Terminal;
10use ratatui::backend::CrosstermBackend;
11use ratatui::layout::Rect;
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::Paragraph;
15use tokio::sync::oneshot;
16
17use super::i18n::strings;
18
19const BANNER: &[&str] = &[
20    " ██████╗██╗███╗   ██╗██████╗ ███████╗██████╗ ",
21    "██╔════╝██║████╗  ██║██╔══██╗██╔════╝██╔══██╗",
22    "██║     ██║██╔██╗ ██║██║  ██║█████╗  ██████╔╝",
23    "██║     ██║██║╚██╗██║██║  ██║██╔══╝  ██╔══██╗",
24    "╚██████╗██║██║ ╚████║██████╔╝███████╗██║  ██║",
25    " ╚═════╝╚═╝╚═╝  ╚═══╝╚═════╝ ╚══════╝╚═╝  ╚═╝",
26];
27
28const FRAME_INTERVAL: Duration = Duration::from_millis(55);
29/// Show the animation for at least this long even if network setup finishes
30/// quickly — otherwise the splash just flickers and the user can't tell what
31/// they saw.
32const MIN_SPLASH: Duration = Duration::from_millis(300);
33
34fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
35    let v = a as f32 + (b as f32 - a as f32) * t;
36    v.round().clamp(0.0, 255.0) as u8
37}
38
39fn lerp_rgb(a: (u8, u8, u8), b: (u8, u8, u8), t: f32) -> (u8, u8, u8) {
40    (
41        lerp_u8(a.0, b.0, t),
42        lerp_u8(a.1, b.1, t),
43        lerp_u8(a.2, b.2, t),
44    )
45}
46
47/// Sample the fire gradient. `row` is 0.0 at the top of the banner, 1.0 at the
48/// bottom; `col` is the character column. `t` is animation seconds.
49fn fire_color(t: f32, row: f32, col: f32) -> Color {
50    // Two out-of-phase wobbles per column give the flame-flicker feel.
51    let mut p = row;
52    p += (t * 3.7 + col * 0.55).sin() * 0.13;
53    p += (t * 2.1 + col * 0.31).cos() * 0.08;
54    let p = p.clamp(0.0, 1.0);
55
56    let (r, g, b) = if p < 0.35 {
57        lerp_rgb((255, 190, 60), (255, 140, 20), p / 0.35)
58    } else if p < 0.70 {
59        lerp_rgb((255, 140, 20), (240, 90, 0), (p - 0.35) / 0.35)
60    } else {
61        lerp_rgb((240, 90, 0), (170, 40, 0), (p - 0.70) / 0.30)
62    };
63    Color::Rgb(r, g, b)
64}
65
66fn build_frame(time: f32) -> Vec<Line<'static>> {
67    let total = (BANNER.len() as f32 - 1.0).max(1.0);
68    let mut lines: Vec<Line<'static>> = Vec::with_capacity(BANNER.len() + 2);
69    for (row_idx, row) in BANNER.iter().enumerate() {
70        let row_p = row_idx as f32 / total;
71        let mut spans: Vec<Span<'static>> = Vec::with_capacity(row.chars().count());
72        for (col_idx, ch) in row.chars().enumerate() {
73            let style = if ch == ' ' {
74                Style::default()
75            } else {
76                Style::default()
77                    .fg(fire_color(time, row_p, col_idx as f32))
78                    .add_modifier(Modifier::BOLD)
79            };
80            spans.push(Span::styled(ch.to_string(), style));
81        }
82        lines.push(Line::from(spans));
83    }
84
85    // Current UTC clock + pulsing "loading" tagline beneath the banner.
86    lines.push(Line::from(""));
87    let now = Utc::now().format("%H:%M:%S").to_string();
88    let dots = ((time * 2.5) as usize) % 4;
89    let mut tagline = format!("{now}  Loading Phoenix markets");
90    for _ in 0..dots {
91        tagline.push('.');
92    }
93    let pulse = ((time * 1.8).sin() * 0.5 + 0.5).clamp(0.0, 1.0);
94    let ember = lerp_rgb((180, 60, 0), (255, 170, 40), pulse);
95    lines.push(Line::from(Span::styled(
96        tagline,
97        Style::default().fg(Color::Rgb(ember.0, ember.1, ember.2)),
98    )));
99
100    // Progress bar — we don't know the total setup duration up-front, so fill
101    // asymptotically toward 100% (1 - exp(-t/τ)). Reads as steady forward
102    // motion without ever falsely claiming "done".
103    let banner_w = BANNER[0].chars().count();
104    let progress = 1.0 - (-time / 1.0).exp();
105    let filled = (progress * banner_w as f32).round() as usize;
106    let filled = filled.min(banner_w);
107    let mut bar_spans: Vec<Span<'static>> = Vec::with_capacity(banner_w);
108    for col in 0..banner_w {
109        if col < filled {
110            let row_p = (col as f32 / banner_w.max(1) as f32).clamp(0.0, 1.0);
111            bar_spans.push(Span::styled(
112                "█",
113                Style::default().fg(fire_color(time, row_p, col as f32)),
114            ));
115        } else {
116            bar_spans.push(Span::styled(
117                "░",
118                Style::default().fg(Color::Rgb(60, 30, 15)),
119            ));
120        }
121    }
122    lines.push(Line::from(bar_spans));
123
124    // Credit — right-aligned via leading padding so it lines up with the
125    // banner's right edge regardless of banner width.
126    let credit = "powered by Cosmic Markets";
127    let pad = banner_w.saturating_sub(credit.chars().count());
128    let mut credit_text = String::with_capacity(pad + credit.len());
129    for _ in 0..pad {
130        credit_text.push(' ');
131    }
132    credit_text.push_str(credit);
133    lines.push(Line::from(Span::styled(
134        credit_text,
135        Style::default()
136            .fg(Color::Rgb(170, 110, 70))
137            .add_modifier(Modifier::ITALIC),
138    )));
139
140    // Risk disclaimer — dim, italic, left-aligned so it reads as fine print
141    // rather than competing with the banner.
142    let disclaimer = strings().splash_risk_disclaimer;
143    lines.push(Line::from(Span::styled(
144        disclaimer,
145        Style::default()
146            .fg(Color::Rgb(110, 70, 50))
147            .add_modifier(Modifier::ITALIC),
148    )));
149    lines
150}
151
152fn draw_frame(terminal: &mut Terminal<CrosstermBackend<Stdout>>, time: f32) -> std::io::Result<()> {
153    let lines = build_frame(time);
154    terminal.draw(|f| {
155        let area = f.area();
156        let banner_w = BANNER[0].chars().count() as u16;
157        let banner_h = lines.len() as u16;
158        if area.width < banner_w || area.height < banner_h {
159            return;
160        }
161        let x = (area.width - banner_w) / 2;
162        let y = area.height.saturating_sub(banner_h) / 2;
163        let target = Rect::new(x, y, banner_w, banner_h);
164        f.render_widget(Paragraph::new(lines), target);
165    })?;
166    Ok(())
167}
168
169/// Run the splash on a blocking thread. Stops as soon as `stop` is signalled or
170/// dropped, then returns the terminal so the caller can hand it to the real
171/// TUI.
172pub fn spawn(
173    mut terminal: Terminal<CrosstermBackend<Stdout>>,
174    mut stop: oneshot::Receiver<()>,
175) -> tokio::task::JoinHandle<Terminal<CrosstermBackend<Stdout>>> {
176    tokio::task::spawn_blocking(move || {
177        let _ = terminal.clear();
178        let start = Instant::now();
179        loop {
180            let stopped = matches!(
181                stop.try_recv(),
182                Ok(_) | Err(oneshot::error::TryRecvError::Closed)
183            );
184            if stopped && start.elapsed() >= MIN_SPLASH {
185                break;
186            }
187            let t = start.elapsed().as_secs_f32();
188            let _ = draw_frame(&mut terminal, t);
189            std::thread::sleep(FRAME_INTERVAL);
190        }
191        terminal
192    })
193}