Skip to main content

hjkl_splash/
lib.rs

1//! `hjkl-splash` — rendering-agnostic splash-screen animation.
2//!
3//! Emits pure [`SplashCell`] items via an iterator; consumers (TUI/GUI)
4//! translate to their own rendering surface. The crate owns its time source —
5//! [`Splash::cells`] takes `&self` and reads the wall clock internally, so
6//! consumers cannot accidentally desynchronise the animation by skipping a
7//! per-iteration `advance()` call (the v0.1 footgun).
8
9use std::time::{Duration, Instant};
10
11pub mod presets;
12pub mod start_screen;
13
14/// 24-bit RGB colour value.
15#[derive(Copy, Clone, Debug, PartialEq, Eq)]
16pub struct Rgb(pub u8, pub u8, pub u8);
17
18/// Describes what role a cell plays in the current animation frame.
19#[derive(Copy, Clone, Debug, PartialEq, Eq)]
20pub enum CellKind {
21    /// Static art glyph — renderer should paint dim.
22    Art,
23    /// Trail cell — `age` is 0 (just-passed) up to `trail_len - 1` (oldest).
24    Trail { age: u8 },
25    /// Current cursor position — renderer should highlight.
26    Cursor,
27}
28
29/// A single cell to be painted this tick.
30#[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/// Bounding box of the art block within the terminal/canvas.
39#[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    /// Center an `art_rows × art_cols` block within a viewport, leaving a
49    /// little headroom for hint text below (matching the canonical hjkl
50    /// placement: `(height - art_rows - 4) / 2`).
51    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
63/// Default tick period — ~8 Hz (120 ms). Matches the canonical hjkl feel
64/// (in v0.1 and earlier, manual `advance()` was driven by a 120 ms poll
65/// timeout in the consumer's event loop, so this preserves that cadence by
66/// default). Consumers wanting smoother motion can opt in via
67/// [`Splash::with_period`].
68pub const DEFAULT_PERIOD: Duration = Duration::from_millis(120);
69
70/// Default trail length.
71pub const DEFAULT_TRAIL_LEN: u8 = 6;
72
73/// How the splash derives the current `tick`.
74#[derive(Copy, Clone, Debug)]
75enum TimeSource {
76    /// Anchor + period; tick = (now - anchor) / period.
77    Wall { anchor: Instant, period: Duration },
78    /// Pinned tick value — used for deterministic tests.
79    Fixed(u64),
80}
81
82/// The animation state machine.
83///
84/// Default time source is the wall clock at 30 Hz; consumers call
85/// [`Splash::cells`] every redraw and the animation marches at the configured
86/// period regardless of redraw rate. For deterministic frame stepping (snapshot
87/// tests, recorded playback) construct via [`Splash::fixed_tick`] or pin an
88/// existing splash with [`Splash::set_fixed_tick`].
89pub 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    /// Wall-clock-driven splash with the default ~8 Hz tick rate and 6-cell
98    /// trail. The clock anchor is `Instant::now()`.
99    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    /// Deterministic splash pinned to a fixed `tick`. Useful for snapshot tests
112    /// and recorded playback. The tick value is what [`Splash::cells`] sees.
113    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    /// Override the trail length (default [`DEFAULT_TRAIL_LEN`]).
123    pub fn with_trail_len(mut self, n: u8) -> Self {
124        self.trail_len = n;
125        self
126    }
127
128    /// Override the wall-clock tick period (default [`DEFAULT_PERIOD`]).
129    /// No-op when the splash is in fixed-tick mode.
130    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    /// Reset the wall-clock anchor to "now". No-op when fixed-tick.
138    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    /// Pin the splash to `tick`, switching it into fixed-tick mode.
148    /// Subsequent calls to [`Splash::cells`] return frames for that tick.
149    pub fn set_fixed_tick(&mut self, tick: u64) {
150        self.time = TimeSource::Fixed(tick);
151    }
152
153    /// Current tick — derived from the wall clock or the pinned value.
154    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    /// Current trail length.
166    pub fn trail_len(&self) -> u8 {
167        self.trail_len
168    }
169
170    /// Yield every cell to paint for the current frame. Idempotent within a
171    /// tick window — calling it 1× or 100× per period produces the same cells.
172    ///
173    /// Order:
174    /// 1. All art-glyph cells from `self.art` lines (`CellKind::Art`).
175    /// 2. The trail (oldest → newest), then the cursor cell. Later iterations
176    ///    overwrite earlier, so naive renderers can paint in iteration order.
177    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        // oldest first (age = trail_len) → cursor last (age = 0)
207        (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
231/// Default ramp for trail age → [`Rgb`].
232///
233/// Age 0 is the brightest (just-passed); age ≥ 5 clamps to the dimmest.
234pub fn default_trail_color(age: u8) -> Rgb {
235    match age {
236        0 => Rgb(0xe5, 0xe9, 0xf0), // near-white
237        1 => Rgb(0xa0, 0xa8, 0xb8), // mid-bright
238        2 => Rgb(0x60, 0x68, 0x78), // mid
239        3 => Rgb(0x38, 0x40, 0x50), // dim
240        4 => Rgb(0x20, 0x26, 0x32), // very dim
241        _ => Rgb(0x10, 0x14, 0x1c), // barely visible
242    }
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        // Wall-clock splash; same tick window should yield identical cells.
272        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        // pin to tick 2 → cursor_idx = 2, trail covers path[1]
297        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        // origin_y = (20 - 5 - 4) / 2 = 5
340        assert_eq!(layout.origin_y, 5);
341        // origin_x = (40 - 32) / 2 = 4
342        assert_eq!(layout.origin_x, 4);
343    }
344}