hefesto-widgets 0.7.2

Ratatui widgets for the Hefesto TUI toolkit: popups, scrollable lists, trees, text input and spinners
Documentation
use ratatui::{
    buffer::Buffer,
    layout::{Constraint, Layout, Rect},
    style::Style,
    widgets::{Block, Borders, List, ListItem, ListState, StatefulWidget, Widget},
};

use crate::{default_scrollbar, default_scrollbar_state, BORDER_GRAY, DEFAULT_HIGHLIGHT, HIGHLIGHT_SYMBOL};

#[derive(Clone)]
pub struct ScrollListState {
    pub list_state: ListState,
    pub follow: bool,
}

impl Default for ScrollListState {
    fn default() -> Self {
        Self {
            list_state: ListState::default(),
            follow: true,
        }
    }
}

impl ScrollListState {
    pub fn select(&mut self, index: Option<usize>) {
        self.list_state.select(index);
    }

    pub fn next(&mut self, item_count: usize) {
        let i = self.list_state.selected().map_or(0, |s| s + 1);
        let max = item_count.saturating_sub(1);
        self.list_state.select(Some(i.min(max)));
    }

    pub fn previous(&mut self) {
        let i = self.list_state.selected().map_or(0, |s| s.saturating_sub(1));
        self.list_state.select(Some(i));
    }

    pub fn last(&mut self, item_count: usize) {
        self.list_state.select(Some(item_count.saturating_sub(1)));
    }
}

/// Flexible scrollable list backed by `Vec<ListItem>`.
///
/// Use `new` / `new_styled` for simple string items,
/// or `new_list` to pass pre-built `ListItem` values
/// (e.g. with indentation for tree views).
pub struct ScrollList<'a> {
    items: Vec<ListItem<'a>>,
    highlight_style: Style,
    highlight_symbol: String,
    borders: Borders,
    border_style: Style,
}

impl<'a> ScrollList<'a> {
    pub fn new(items: Vec<String>) -> Self {
        Self {
            items: items
                .into_iter()
                .map(|s| ListItem::new(s))
                .collect(),
            highlight_style: DEFAULT_HIGHLIGHT,
            highlight_symbol: HIGHLIGHT_SYMBOL.to_string(),
            borders: Borders::NONE,
            border_style: Style::new().fg(BORDER_GRAY),
        }
    }

    pub fn new_styled(items: Vec<(String, Style)>) -> Self {
        Self {
            items: items
                .into_iter()
                .map(|(s, style)| ListItem::new(s).style(style))
                .collect(),
            highlight_style: DEFAULT_HIGHLIGHT,
            highlight_symbol: HIGHLIGHT_SYMBOL.to_string(),
            borders: Borders::NONE,
            border_style: Style::new().fg(BORDER_GRAY),
        }
    }

    /// Build from pre-constructed `ListItem` values.
    /// Useful when items need custom layout (indentation, icons, etc.).
    pub fn new_list(items: Vec<ListItem<'a>>) -> Self {
        Self {
            items,
            highlight_style: DEFAULT_HIGHLIGHT,
            highlight_symbol: HIGHLIGHT_SYMBOL.to_string(),
            borders: Borders::NONE,
            border_style: Style::new().fg(BORDER_GRAY),
        }
    }

    pub fn style(mut self, style: Style) -> Self {
        for item in &mut self.items {
            *item = item.clone().style(style);
        }
        self
    }

    pub fn highlight_style(mut self, style: Style) -> Self {
        self.highlight_style = style;
        self
    }

    pub fn highlight_symbol(mut self, symbol: &str) -> Self {
        self.highlight_symbol = symbol.to_string();
        self
    }

    pub fn borders(mut self, borders: Borders) -> Self {
        self.borders = borders;
        self
    }

    pub fn border_style(mut self, style: Style) -> Self {
        self.border_style = style;
        self
    }
}

impl StatefulWidget for ScrollList<'_> {
    type State = ScrollListState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        if state.follow && !self.items.is_empty() {
            state
                .list_state
                .select(Some(self.items.len().saturating_sub(1)));
        }

        let inner = if self.borders != Borders::NONE {
            let block = Block::bordered()
                .borders(self.borders)
                .border_style(self.border_style);
            let inner = block.inner(area);
            block.render(area, buf);
            inner
        } else {
            area
        };

        let hchunks = Layout::horizontal([
            Constraint::Min(1),
            Constraint::Length(1),
        ])
        .split(inner);

        let list_area = hchunks[0];
        let scrollbar_area = hchunks[1];
        let total = self.items.len();
        let mut list = List::new(self.items)
            .highlight_style(self.highlight_style)
            .scroll_padding(1);
        if !self.highlight_symbol.is_empty() {
            list = list.highlight_symbol(self.highlight_symbol);
        }

        StatefulWidget::render(list, list_area, buf, &mut state.list_state);

        if state.list_state.selected().is_none() && total > 0 {
            state.list_state.select(Some(0));
        }

        if total > list_area.height as usize {
            let visible = list_area.height as usize;
            let max_offset = total.saturating_sub(visible);
            let offset = state.list_state.offset();
            let position = if max_offset > 0 {
                offset * (total - 1) / max_offset
            } else {
                0
            };
            let mut scrollbar_state = default_scrollbar_state(total, position);
            StatefulWidget::render(
                default_scrollbar(),
                scrollbar_area,
                buf,
                &mut scrollbar_state,
            );
        }
    }
}
#[cfg(test)]
mod tests;