1use 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);
29const 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
47fn fire_color(t: f32, row: f32, col: f32) -> Color {
50 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 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 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 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 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
169pub 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}