1use std::time::{Duration, Instant};
10
11pub mod presets;
12pub mod start_screen;
13
14#[derive(Copy, Clone, Debug, PartialEq, Eq)]
16pub struct Rgb(pub u8, pub u8, pub u8);
17
18#[derive(Copy, Clone, Debug, PartialEq, Eq)]
20pub enum CellKind {
21 Art,
23 Trail { age: u8 },
25 Cursor,
27}
28
29#[derive(Copy, Clone, Debug)]
31pub struct SplashCell {
32 pub x: u16,
33 pub y: u16,
34 pub ch: char,
35 pub kind: CellKind,
36}
37
38#[derive(Copy, Clone, Debug)]
40pub struct Layout {
41 pub origin_x: u16,
42 pub origin_y: u16,
43 pub rows: u16,
44 pub cols: u16,
45}
46
47impl Layout {
48 pub fn centered(viewport_w: u16, viewport_h: u16, art_rows: u16, art_cols: u16) -> Self {
52 let origin_y = viewport_h.saturating_sub(art_rows + 4) / 2;
53 let origin_x = viewport_w.saturating_sub(art_cols) / 2;
54 Self {
55 origin_x,
56 origin_y,
57 rows: art_rows,
58 cols: art_cols,
59 }
60 }
61}
62
63pub const DEFAULT_PERIOD: Duration = Duration::from_millis(120);
69
70pub const DEFAULT_TRAIL_LEN: u8 = 6;
72
73#[derive(Copy, Clone, Debug)]
75enum TimeSource {
76 Wall { anchor: Instant, period: Duration },
78 Fixed(u64),
80}
81
82pub struct Splash<'a> {
90 art: &'a str,
91 path: &'a [(u8, u8, char)],
92 trail_len: u8,
93 time: TimeSource,
94}
95
96impl<'a> Splash<'a> {
97 pub fn new(art: &'a str, path: &'a [(u8, u8, char)]) -> Self {
100 Self {
101 art,
102 path,
103 trail_len: DEFAULT_TRAIL_LEN,
104 time: TimeSource::Wall {
105 anchor: Instant::now(),
106 period: DEFAULT_PERIOD,
107 },
108 }
109 }
110
111 pub fn fixed_tick(art: &'a str, path: &'a [(u8, u8, char)], tick: u64) -> Self {
114 Self {
115 art,
116 path,
117 trail_len: DEFAULT_TRAIL_LEN,
118 time: TimeSource::Fixed(tick),
119 }
120 }
121
122 pub fn with_trail_len(mut self, n: u8) -> Self {
124 self.trail_len = n;
125 self
126 }
127
128 pub fn with_period(mut self, period: Duration) -> Self {
131 if let TimeSource::Wall { anchor, .. } = self.time {
132 self.time = TimeSource::Wall { anchor, period };
133 }
134 self
135 }
136
137 pub fn reset(&mut self) {
139 if let TimeSource::Wall { period, .. } = self.time {
140 self.time = TimeSource::Wall {
141 anchor: Instant::now(),
142 period,
143 };
144 }
145 }
146
147 pub fn set_fixed_tick(&mut self, tick: u64) {
150 self.time = TimeSource::Fixed(tick);
151 }
152
153 pub fn tick(&self) -> u64 {
155 match self.time {
156 TimeSource::Wall { anchor, period } => {
157 let elapsed = Instant::now().saturating_duration_since(anchor);
158 let period_nanos = period.as_nanos().max(1);
159 (elapsed.as_nanos() / period_nanos) as u64
160 }
161 TimeSource::Fixed(t) => t,
162 }
163 }
164
165 pub fn trail_len(&self) -> u8 {
167 self.trail_len
168 }
169
170 pub fn cells(&self, layout: Layout) -> impl Iterator<Item = SplashCell> + '_ {
178 let tick = self.tick();
179 let art_cells = self.art_cells(layout);
180 let trail_cells = self.trail_cells(layout, tick);
181 art_cells.chain(trail_cells)
182 }
183
184 fn art_cells(&self, layout: Layout) -> impl Iterator<Item = SplashCell> + '_ {
185 self.art
186 .lines()
187 .take(layout.rows as usize)
188 .enumerate()
189 .flat_map(move |(row_idx, line)| {
190 line.chars()
191 .enumerate()
192 .map(move |(col_idx, ch)| SplashCell {
193 x: layout.origin_x + col_idx as u16,
194 y: layout.origin_y + row_idx as u16,
195 ch,
196 kind: CellKind::Art,
197 })
198 })
199 }
200
201 fn trail_cells(&self, layout: Layout, tick: u64) -> impl Iterator<Item = SplashCell> + '_ {
202 let path_len = self.path.len();
203 let trail_len = self.trail_len as usize;
204 let cursor_idx = tick as usize % path_len;
205
206 (0..=trail_len).rev().map(move |age| {
208 let idx = if cursor_idx + path_len >= age {
209 (cursor_idx + path_len - age) % path_len
210 } else {
211 0
212 };
213 let (row, col, ch) = self.path[idx];
214 let kind = if age == 0 {
215 CellKind::Cursor
216 } else {
217 CellKind::Trail {
218 age: (age - 1) as u8,
219 }
220 };
221 SplashCell {
222 x: layout.origin_x + col as u16,
223 y: layout.origin_y + row as u16,
224 ch,
225 kind,
226 }
227 })
228 }
229}
230
231pub fn default_trail_color(age: u8) -> Rgb {
235 match age {
236 0 => Rgb(0xe5, 0xe9, 0xf0), 1 => Rgb(0xa0, 0xa8, 0xb8), 2 => Rgb(0x60, 0x68, 0x78), 3 => Rgb(0x38, 0x40, 0x50), 4 => Rgb(0x20, 0x26, 0x32), _ => Rgb(0x10, 0x14, 0x1c), }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn fixed_tick_pins_value() {
251 let path: &[(u8, u8, char)] = &[(0, 0, 'a'), (0, 1, 'b'), (0, 2, 'c')];
252 let splash = Splash::fixed_tick("abc", path, 7);
253 assert_eq!(splash.tick(), 7);
254 }
255
256 #[test]
257 fn wall_clock_advances_with_period() {
258 let path: &[(u8, u8, char)] = &[(0, 0, 'a')];
259 let splash = Splash::new("a", path).with_period(Duration::from_millis(1));
260 let t0 = splash.tick();
261 std::thread::sleep(Duration::from_millis(5));
262 let t1 = splash.tick();
263 assert!(
264 t1 >= t0 + 4,
265 "expected at least 4 ticks elapsed, got {t0} -> {t1}"
266 );
267 }
268
269 #[test]
270 fn cells_idempotent_across_calls_without_advance() {
271 let art = "abc";
273 let path: &[(u8, u8, char)] = &[(0, 0, 'a'), (0, 1, 'b'), (0, 2, 'c')];
274 let splash = Splash::new(art, path).with_period(Duration::from_secs(60));
275 let layout = Layout {
276 origin_x: 0,
277 origin_y: 0,
278 rows: 1,
279 cols: 3,
280 };
281 let frame_a: Vec<_> = splash.cells(layout).collect();
282 let frame_b: Vec<_> = splash.cells(layout).collect();
283 assert_eq!(frame_a.len(), frame_b.len());
284 for (a, b) in frame_a.iter().zip(frame_b.iter()) {
285 assert_eq!(a.x, b.x);
286 assert_eq!(a.y, b.y);
287 assert_eq!(a.ch, b.ch);
288 assert_eq!(a.kind, b.kind);
289 }
290 }
291
292 #[test]
293 fn splash_emits_art_then_trail_then_cursor() {
294 let art = "abc";
295 let path: &[(u8, u8, char)] = &[(0, 0, 'a'), (0, 1, 'b'), (0, 2, 'c')];
296 let splash = Splash::fixed_tick(art, path, 2).with_trail_len(1);
298
299 let layout = Layout {
300 origin_x: 0,
301 origin_y: 0,
302 rows: 1,
303 cols: 3,
304 };
305 let cells: Vec<_> = splash.cells(layout).collect();
306
307 let art_cells: Vec<_> = cells.iter().filter(|c| c.kind == CellKind::Art).collect();
308 assert_eq!(art_cells.len(), 3);
309
310 let trail_cells: Vec<_> = cells
311 .iter()
312 .filter(|c| matches!(c.kind, CellKind::Trail { .. }))
313 .collect();
314 assert_eq!(trail_cells.len(), 1);
315 assert_eq!(trail_cells[0].x, 1);
316 assert_eq!(trail_cells[0].kind, CellKind::Trail { age: 0 });
317
318 let cursor: Vec<_> = cells
319 .iter()
320 .filter(|c| c.kind == CellKind::Cursor)
321 .collect();
322 assert_eq!(cursor.len(), 1);
323 assert_eq!(cursor[0].x, 2);
324 assert_eq!(cursor[0].ch, 'c');
325 }
326
327 #[test]
328 fn default_trail_color_clamps_at_high_age() {
329 let age0 = default_trail_color(0);
330 let age5 = default_trail_color(5);
331 let age10 = default_trail_color(10);
332 assert!(age0.0 > age5.0, "age0 red should be brighter than age5");
333 assert_eq!(age5, age10);
334 }
335
336 #[test]
337 fn layout_centers_art() {
338 let layout = Layout::centered(40, 20, 5, 32);
339 assert_eq!(layout.origin_y, 5);
341 assert_eq!(layout.origin_x, 4);
343 }
344}