hjkl-splash 0.3.0

Rendering-agnostic splash-screen animation for kryptic-sh projects.
Documentation
//! Renderer-agnostic start-screen surface.
//!
//! `StartScreen` owns the data model (version, hints, recent files, palette).
//! The `ratatui` feature gate adds a [`render`] function that paints the
//! surface into a [`ratatui::Frame`].
//!
//! A `gui` feature stub is also declared for future floem integration.

use std::path::PathBuf;

use crate::{Splash, presets};

/// Colour palette the start-screen render uses.  Both TUI and GUI renderers
/// convert these `Rgb` values to their own colour types.
#[derive(Clone, Debug)]
pub struct StartScreenTheme {
    /// Primary text / art colour.
    pub text_dim: crate::Rgb,
    /// Cursor highlight text colour.
    pub text: crate::Rgb,
    /// Cursor highlight background colour.
    pub cursor_line_bg: crate::Rgb,
}

impl Default for StartScreenTheme {
    fn default() -> Self {
        // Nord-ish fallback palette.
        Self {
            text_dim: crate::Rgb(0x4c, 0x56, 0x6a),
            text: crate::Rgb(0xd8, 0xde, 0xe9),
            cursor_line_bg: crate::Rgb(0x3b, 0x42, 0x52),
        }
    }
}

/// Data model for the start screen — rendering-agnostic.
pub struct StartScreen {
    /// Animated splash state machine.
    pub splash: Splash<'static>,
    /// Semver string shown below the art (e.g. `"0.21.9"`).
    pub version: String,
    /// `(command, description)` key hint pairs displayed below the CTA.
    pub key_hints: Vec<(String, String)>,
    /// Recently-opened file paths (reserved for future use).
    pub recent_files: Vec<PathBuf>,
    /// Colour palette.
    pub palette: StartScreenTheme,
}

impl StartScreen {
    /// Construct a `StartScreen` with the hjkl preset splash and the supplied
    /// version string.  Key hints and recent files are pre-populated with
    /// sensible defaults; callers can mutate after construction.
    pub fn build(version: &str) -> Self {
        Self {
            splash: Splash::new(presets::hjkl::ART, presets::hjkl::PATH),
            version: version.to_owned(),
            key_hints: vec![
                (":e <file>".to_owned(), "open a file".to_owned()),
                (":q".to_owned(), "quit".to_owned()),
            ],
            recent_files: Vec::new(),
            palette: StartScreenTheme::default(),
        }
    }
}

// ── TUI render ────────────────────────────────────────────────────────────────

#[cfg(feature = "ratatui")]
mod tui {
    use ratatui::{
        Frame,
        layout::Rect,
        style::{Color, Style},
        text::{Line, Span},
        widgets::Paragraph,
    };

    use crate::{CellKind, Layout, default_trail_color, presets};

    use super::StartScreen;

    /// Paint `screen` into `frame` at `area`.
    ///
    /// Renders the animated splash art, a call-to-action line, and the key
    /// hints defined in [`StartScreen::key_hints`].
    pub fn render(frame: &mut Frame, area: Rect, screen: &StartScreen) {
        let layout = Layout::centered(
            area.width,
            area.height,
            presets::hjkl::ROWS,
            presets::hjkl::COLS,
        );

        let art_top = area.y + layout.origin_y;
        let art_left = area.x + layout.origin_x;

        let abs_layout = crate::Layout {
            origin_x: art_left,
            origin_y: art_top,
            ..layout
        };

        let palette = &screen.palette;
        let text_dim: Color = palette.text_dim.into();
        let text: Color = palette.text.into();
        let cursor_bg: Color = palette.cursor_line_bg.into();

        let buf = frame.buffer_mut();
        for cell in screen.splash.cells(abs_layout) {
            if cell.x >= area.x + area.width || cell.y >= area.y + area.height {
                continue;
            }
            match cell.kind {
                CellKind::Art => {
                    if let Some(buf_cell) = buf.cell_mut((cell.x, cell.y)) {
                        buf_cell.set_char(cell.ch);
                        buf_cell.set_style(Style::default().fg(text_dim));
                    }
                }
                CellKind::Trail { age } => {
                    let color: Color = default_trail_color(age).into();
                    if let Some(buf_cell) = buf.cell_mut((cell.x, cell.y)) {
                        buf_cell.set_char(cell.ch);
                        buf_cell.set_style(Style::default().fg(color));
                    }
                }
                CellKind::Cursor => {
                    if let Some(buf_cell) = buf.cell_mut((cell.x, cell.y)) {
                        buf_cell.set_char(cell.ch);
                        buf_cell.set_style(Style::default().fg(text).bg(cursor_bg));
                    }
                }
            }
        }

        let hint_style = Style::default().fg(text_dim);
        let cta = "press any key to start editing";

        let cmd_col_width = screen
            .key_hints
            .iter()
            .map(|(cmd, _)| cmd.len())
            .max()
            .unwrap_or(0);
        let gap = 3usize;
        let block_width = screen
            .key_hints
            .iter()
            .map(|(_, desc)| cmd_col_width + gap + desc.len())
            .max()
            .unwrap_or(0) as u16;

        let cta_y = art_top + presets::hjkl::ROWS + 1;
        if cta_y < area.y + area.height {
            let cta_len = cta.len() as u16;
            let x = area.x + area.width.saturating_sub(cta_len) / 2;
            let rect = Rect {
                x,
                y: cta_y,
                width: cta_len.min(area.width),
                height: 1,
            };
            frame.render_widget(
                Paragraph::new(Line::from(vec![Span::styled(cta, hint_style)])),
                rect,
            );
        }

        let block_x = area.x + area.width.saturating_sub(block_width) / 2;
        for (i, (cmd, desc)) in screen.key_hints.iter().enumerate() {
            let y = art_top + presets::hjkl::ROWS + 3 + i as u16;
            if y >= area.y + area.height {
                break;
            }
            let line = format!("{cmd:<cmd_col_width$}{:gap$}{desc}", "");
            let rect = Rect {
                x: block_x,
                y,
                width: block_width.min(area.width),
                height: 1,
            };
            frame.render_widget(
                Paragraph::new(Line::from(vec![Span::styled(line, hint_style)])),
                rect,
            );
        }
    }
}

#[cfg(feature = "ratatui")]
pub use tui::render;

// ── GUI stub ──────────────────────────────────────────────────────────────────

/// GUI render stub — floem integration lands in a future release.
///
/// Currently a no-op; present so `hjkl-gui` can compile-test the surface
/// without waiting for the real floem adapter.
#[cfg(feature = "gui")]
pub fn render_gui(_screen: &StartScreen) {
    // TODO: floem integration (#130 follow-up)
}