hjkl-splash 0.1.0

Rendering-agnostic splash-screen animation for kryptic-sh projects.
Documentation
//! `hjkl-splash` — rendering-agnostic splash-screen animation.
//!
//! Emits pure [`SplashCell`] items via an iterator; consumers (TUI/GUI)
//! translate to their own rendering surface.

pub mod presets;

#[cfg(feature = "ratatui")]
mod ratatui_adapter;

// The ratatui_adapter module only contains trait impls; no re-export needed.

/// 24-bit RGB colour value.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct Rgb(pub u8, pub u8, pub u8);

/// Describes what role a cell plays in the current animation frame.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum CellKind {
    /// Static art glyph — renderer should paint dim.
    Art,
    /// Trail cell — `age` is 0 (just-passed) up to `trail_len - 1` (oldest).
    Trail { age: u8 },
    /// Current cursor position — renderer should highlight.
    Cursor,
}

/// A single cell to be painted this tick.
#[derive(Copy, Clone, Debug)]
pub struct SplashCell {
    pub x: u16,
    pub y: u16,
    pub ch: char,
    pub kind: CellKind,
}

/// Bounding box of the art block within the terminal/canvas.
#[derive(Copy, Clone, Debug)]
pub struct Layout {
    pub origin_x: u16,
    pub origin_y: u16,
    pub rows: u16,
    pub cols: u16,
}

impl Layout {
    /// Center an `art_rows × art_cols` block within a viewport, leaving a
    /// little headroom for hint text below (matching the original impl:
    /// `(height - art_rows - 4) / 2`).
    pub fn centered(viewport_w: u16, viewport_h: u16, art_rows: u16, art_cols: u16) -> Self {
        let origin_y = viewport_h.saturating_sub(art_rows + 4) / 2;
        let origin_x = viewport_w.saturating_sub(art_cols) / 2;
        Self {
            origin_x,
            origin_y,
            rows: art_rows,
            cols: art_cols,
        }
    }
}

/// The animation state machine.
pub struct Splash<'a> {
    art: &'a str,
    path: &'a [(u8, u8, char)],
    trail_len: u8,
    tick: u64,
}

impl<'a> Splash<'a> {
    /// Create a new splash with the default trail length of 6.
    pub fn new(art: &'a str, path: &'a [(u8, u8, char)]) -> Self {
        Self {
            art,
            path,
            trail_len: 6,
            tick: 0,
        }
    }

    /// Override the trail length.
    pub fn with_trail_len(mut self, n: u8) -> Self {
        self.trail_len = n;
        self
    }

    /// Advance the animation by one tick.
    pub fn advance(&mut self) {
        self.tick = self.tick.wrapping_add(1);
    }

    /// Current tick count.
    pub fn tick(&self) -> u64 {
        self.tick
    }

    /// Current trail length.
    pub fn trail_len(&self) -> u8 {
        self.trail_len
    }

    /// Yield every cell to paint for the current tick.
    ///
    /// Order:
    /// 1. All art-glyph cells from `self.art` lines (`CellKind::Art`) at their
    ///    layout positions.
    /// 2. The trail (oldest → newest), then the cursor cell — later iterations
    ///    overwrite earlier, so naive renderers can paint in iteration order.
    pub fn cells(&self, layout: Layout) -> impl Iterator<Item = SplashCell> + '_ {
        let art_cells = self.art_cells(layout);
        let trail_cells = self.trail_cells(layout);
        art_cells.chain(trail_cells)
    }

    fn art_cells(&self, layout: Layout) -> impl Iterator<Item = SplashCell> + '_ {
        self.art
            .lines()
            .take(layout.rows as usize)
            .enumerate()
            .flat_map(move |(row_idx, line)| {
                line.chars()
                    .enumerate()
                    .map(move |(col_idx, ch)| SplashCell {
                        x: layout.origin_x + col_idx as u16,
                        y: layout.origin_y + row_idx as u16,
                        ch,
                        kind: CellKind::Art,
                    })
            })
    }

    fn trail_cells(&self, layout: Layout) -> impl Iterator<Item = SplashCell> + '_ {
        let path_len = self.path.len();
        let trail_len = self.trail_len as usize;
        let cursor_idx = self.tick as usize % path_len;

        // oldest first (age = trail_len - 1) → cursor last (age = 0)
        (0..=trail_len).rev().map(move |age| {
            let idx = if cursor_idx + path_len >= age {
                (cursor_idx + path_len - age) % path_len
            } else {
                0
            };
            let (row, col, ch) = self.path[idx];
            let kind = if age == 0 {
                CellKind::Cursor
            } else {
                CellKind::Trail {
                    age: (age - 1) as u8,
                }
            };
            SplashCell {
                x: layout.origin_x + col as u16,
                y: layout.origin_y + row as u16,
                ch,
                kind,
            }
        })
    }
}

/// Default ramp for trail age → [`Rgb`].
///
/// Age 0 is the brightest (just-passed); age ≥ 5 clamps to the dimmest.
pub fn default_trail_color(age: u8) -> Rgb {
    match age {
        0 => Rgb(0xe5, 0xe9, 0xf0), // near-white
        1 => Rgb(0xa0, 0xa8, 0xb8), // mid-bright
        2 => Rgb(0x60, 0x68, 0x78), // mid
        3 => Rgb(0x38, 0x40, 0x50), // dim
        4 => Rgb(0x20, 0x26, 0x32), // very dim
        _ => Rgb(0x10, 0x14, 0x1c), // barely visible
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn splash_advances_tick() {
        let path: &[(u8, u8, char)] = &[(0, 0, 'a'), (0, 1, 'b'), (0, 2, 'c')];
        let mut splash = Splash::new("abc", path);
        assert_eq!(splash.tick(), 0);
        splash.advance();
        assert_eq!(splash.tick(), 1);
        splash.advance();
        assert_eq!(splash.tick(), 2);
    }

    #[test]
    fn splash_emits_art_then_trail_then_cursor() {
        // 1-row art, 3-char path, trail_len = 1
        let art = "abc";
        let path: &[(u8, u8, char)] = &[(0, 0, 'a'), (0, 1, 'b'), (0, 2, 'c')];
        let mut splash = Splash::new(art, path).with_trail_len(1);

        // advance to tick 2 → cursor_idx = 2, trail covers path[1]
        splash.advance();
        splash.advance();

        let layout = Layout {
            origin_x: 0,
            origin_y: 0,
            rows: 1,
            cols: 3,
        };
        let cells: Vec<_> = splash.cells(layout).collect();

        // First 3 are art cells
        let art_cells: Vec<_> = cells.iter().filter(|c| c.kind == CellKind::Art).collect();
        assert_eq!(art_cells.len(), 3);

        // Trail cell at path[1] = (0,1,'b') with age=0
        let trail_cells: Vec<_> = cells
            .iter()
            .filter(|c| matches!(c.kind, CellKind::Trail { .. }))
            .collect();
        assert_eq!(trail_cells.len(), 1);
        assert_eq!(trail_cells[0].x, 1);
        assert_eq!(trail_cells[0].kind, CellKind::Trail { age: 0 });

        // Cursor at path[2] = (0,2,'c')
        let cursor: Vec<_> = cells
            .iter()
            .filter(|c| c.kind == CellKind::Cursor)
            .collect();
        assert_eq!(cursor.len(), 1);
        assert_eq!(cursor[0].x, 2);
        assert_eq!(cursor[0].ch, 'c');
    }

    #[test]
    fn default_trail_color_clamps_at_high_age() {
        let age0 = default_trail_color(0);
        let age5 = default_trail_color(5);
        let age10 = default_trail_color(10);
        // age 0 is brightest
        assert!(age0.0 > age5.0, "age0 red should be brighter than age5");
        // age >= 5 clamps to the same value
        assert_eq!(age5, age10);
    }

    #[test]
    fn layout_centers_art() {
        let layout = Layout::centered(40, 20, 5, 32);
        // origin_y = (20 - 5 - 4) / 2 = 11 / 2 = 5
        assert_eq!(layout.origin_y, 5);
        // origin_x = (40 - 32) / 2 = 4
        assert_eq!(layout.origin_x, 4);
    }
}