hefesto-widgets 0.7.3

Ratatui widgets for the Hefesto TUI toolkit: popups, scrollable lists, trees, text input and spinners
Documentation
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Padding, Scrollbar, ScrollbarOrientation, ScrollbarState};

use crate::popup::PopupSize;

// ── Colours ──────────────────────────────────────────

pub const BORDER_GRAY: Color = Color::Rgb(100, 100, 100);
pub const OUTPUT_GRAY: Color = Color::Rgb(120, 120, 120);
pub const FOOTER_GRAY: Color = Color::Rgb(140, 140, 140);

// ── Symbols ──────────────────────────────────────────

pub const CHECK: &str = "";
pub const CROSS: &str = "";

pub const HIGHLIGHT_SYMBOL: &str = "> ";

pub const BACKGROUND_DOT_SYMBOL: &str = "+";

// ── Dimensions ───────────────────────────────────────

pub const POPUP_WIDTH: PopupSize = PopupSize::Fixed(44);

// ── Styles ───────────────────────────────────────────

pub const DEFAULT_HIGHLIGHT: Style = Style::new()
    .fg(Color::Blue)
    .bg(Color::Black)
    .add_modifier(Modifier::REVERSED);

// ── Padding ──────────────────────────────────────────

pub const TEXT_PADDING: Padding = Padding::new(1, 1, 0, 0);

// ── Frames ───────────────────────────────────────────

pub const DEFAULT_FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""];

// ── Dot pattern ──────────────────────────────────────

/// Distribution pattern for the background dot grid.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DotPattern {
    /// Diagonal: `(x + y) % density == 0`. Organic, non-rigid.
    Diagonal,
    /// Grid: `x % density == 0 && y % density == 0`. Structured.
    #[default]
    Grid,
}

// ── Helpers ──────────────────────────────────────────

/// Paints a dot pattern on empty cells in the given area.
///
/// Only affects cells whose symbol is `" "`. Useful as background
/// decoration outside widgets.
///
/// To use as a decorative background outside popups:
/// ```no_run
/// # use ratatui::layout::Rect;
/// # use ratatui::style::Color;
/// # use hefesto_widgets::decorate_with_dots;
/// # fn example(f: &mut ratatui::Frame) {
/// let area = f.area();
/// decorate_with_dots(f.buffer_mut(), area, Color::DarkGray, "\u{00B7}", 4);
/// # }
/// ```
///
/// By default the pattern is grid: `x % density == 0 && y % density == 0`.
/// For a different pattern use [`decorate_with_dots_with_pattern`].
///
/// With `density = 1` every cell is painted, with `density = 4` roughly 1/4
/// of cells, with `density = 10` very sparse.
///
/// This is opt-in: simply don't call it to have no decoration at all.
pub fn decorate_with_dots(buf: &mut Buffer, area: Rect, color: Color, symbol: &str, density: u16) {
    decorate_with_dots_with_pattern(buf, area, color, symbol, density, density, DotPattern::default());
}

/// Same as [`decorate_with_dots`] but with configurable [`DotPattern`].
pub fn decorate_with_dots_with_pattern(
    buf: &mut Buffer,
    area: Rect,
    color: Color,
    symbol: &str,
    density_x: u16,
    density_y: u16,
    pattern: DotPattern,
) {
    if density_x == 0 || density_y == 0 {
        return;
    }
    for y in area.y..area.bottom() {
        for x in area.x..area.right() {
            let hit = match pattern {
                DotPattern::Diagonal => (x + y) % density_x == 0,
                DotPattern::Grid => x % density_x == 0 && y % density_y == 0,
            };
            if !hit {
                continue;
            }
            let cell = &mut buf[(x, y)];
            if cell.symbol() == " " {
                cell.set_symbol(symbol);
                cell.set_fg(color);
            }
        }
    }
}

pub fn default_scrollbar() -> Scrollbar<'static> {
    Scrollbar::new(ScrollbarOrientation::VerticalRight)
        .begin_symbol(None)
        .end_symbol(None)
}

pub fn default_scrollbar_state(total: usize, position: usize) -> ScrollbarState {
    ScrollbarState::new(total).position(position)
}

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

    #[test]
    fn decorate_with_dots_paints_empty_cells() {
        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
        decorate_with_dots(&mut buf, Rect::new(0, 0, 10, 5), Color::Red, "·", 2);
        // Grid with density=2: x%2==0 AND y%2==0
        assert_eq!(buf[(0, 0)].symbol(), "·");
        assert_eq!(buf[(0, 0)].fg, Color::Red);
        assert_eq!(buf[(2, 0)].symbol(), "·");
        assert_eq!(buf[(0, 2)].symbol(), "·");
        assert_eq!(buf[(2, 2)].symbol(), "·");
        // Diagonal positions (1,1) not painted in grid
        assert_eq!(buf[(1, 0)].symbol(), " ");
        assert_eq!(buf[(0, 1)].symbol(), " ");
        assert_eq!(buf[(1, 1)].symbol(), " ");
    }

    #[test]
    fn decorate_with_dots_skips_non_empty_cells() {
        let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1));
        buf[(2, 0)].set_symbol("X");
        decorate_with_dots(&mut buf, Rect::new(0, 0, 5, 1), Color::Blue, "·", 1);
        assert_eq!(buf[(2, 0)].symbol(), "X");
        assert_eq!(buf[(0, 0)].symbol(), "·");
        assert_eq!(buf[(3, 0)].symbol(), "·");
    }

    #[test]
    fn decorate_with_dots_density_5() {
        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
        decorate_with_dots(&mut buf, Rect::new(0, 0, 10, 1), Color::White, "·", 5);
        assert_eq!(buf[(0, 0)].symbol(), "·");
        assert_eq!(buf[(5, 0)].symbol(), "·");
        assert_eq!(buf[(1, 0)].symbol(), " ");
        assert_eq!(buf[(3, 0)].symbol(), " ");
    }

    #[test]
    fn decorate_with_dots_respects_area_bounds() {
        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
        decorate_with_dots(&mut buf, Rect::new(5, 5, 3, 3), Color::Green, "·", 1);
        assert_eq!(buf[(5, 5)].symbol(), "·");
        assert_eq!(buf[(7, 7)].symbol(), "·");
        assert_eq!(buf[(0, 0)].symbol(), " ");
        assert_eq!(buf[(8, 5)].symbol(), " ");
    }

    #[test]
    fn decorate_with_dots_density_zero_is_noop() {
        let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1));
        decorate_with_dots(&mut buf, Rect::new(0, 0, 5, 1), Color::Red, "·", 0);
        assert_eq!(buf[(0, 0)].symbol(), " ");
    }

    #[test]
    fn decorate_with_dots_custom_symbol() {
        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 1));
        decorate_with_dots(&mut buf, Rect::new(0, 0, 3, 1), Color::Cyan, "", 1);
        assert_eq!(buf[(0, 0)].symbol(), "");
        assert_eq!(buf[(0, 0)].fg, Color::Cyan);
    }

    // ── patterns ──

    #[test]
    fn decorate_with_dots_grid_pattern() {
        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
        decorate_with_dots_with_pattern(
            &mut buf, Rect::new(0, 0, 10, 5), Color::Red, "·", 4, 2, DotPattern::Grid,
        );
        assert_eq!(buf[(0, 0)].symbol(), "·");
        assert_eq!(buf[(4, 0)].symbol(), "·");
        assert_eq!(buf[(0, 4)].symbol(), "·");
        assert_eq!(buf[(4, 4)].symbol(), "·");
        assert_eq!(buf[(1, 1)].symbol(), " ");
        assert_eq!(buf[(2, 2)].symbol(), " ");
    }

    #[test]
    fn decorate_with_dots_default_is_grid() {
        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
        decorate_with_dots(&mut buf, Rect::new(0, 0, 4, 1), Color::White, "·", 2);
        // Grid: x%2==0 AND y%2==0. Con y=0, solo x%2==0
        assert_eq!(buf[(0, 0)].symbol(), "·");
        assert_eq!(buf[(2, 0)].symbol(), "·");
        assert_eq!(buf[(1, 0)].symbol(), " ");
        assert_eq!(buf[(3, 0)].symbol(), " ");
    }

    #[test]
    fn dot_pattern_default_is_grid() {
        assert_eq!(DotPattern::default(), DotPattern::Grid);
    }

    #[test]
    fn decorate_with_dots_color_persists() {
        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
        decorate_with_dots(&mut buf, Rect::new(0, 0, 4, 1), Color::Cyan, "·", 2);
        assert_eq!(buf[(0, 0)].fg, Color::Cyan);
    }
}