hefesto-widgets 0.5.2

Ratatui widgets for the Hefesto TUI toolkit: popups, scrollable lists, trees, text input and spinners
Documentation
use std::collections::HashSet;

use ratatui::{
    buffer::Buffer,
    layout::{Constraint, Layout, Rect},
    style::{Color, Style},
    widgets::{Borders, StatefulWidget},
};

use crate::popup::{Popup, PopupSize};
use crate::scroll_list::{ScrollList, ScrollListState};
use crate::text_input::{TextInput, TextInputState};
use crate::{BorderType, BORDER_GRAY, DEFAULT_HIGHLIGHT};

const DEFAULT_SELECTED: Style = DEFAULT_HIGHLIGHT;

#[derive(Clone)]
pub struct ChoosePopup<'a> {
    popup: Popup<'a>,
    items: Vec<(String, Style)>,
    highlight_style: Style,
    filter_input: TextInput<'a>,
    filter_margin_bottom: u16,
    max_selected: Option<usize>,
}

#[derive(Clone)]
pub struct ChoosePopupState {
    pub cursor: usize,
    pub chosen_indices: HashSet<usize>,
    pub max_selected: Option<usize>,
    pub scroll_list_state: ScrollListState,
    pub text_input: TextInputState,
    pub show_filter: bool,
}

impl Default for ChoosePopupState {
    fn default() -> Self {
        Self {
            cursor: 0,
            chosen_indices: HashSet::new(),
            max_selected: None,
            scroll_list_state: ScrollListState::default(),
            text_input: TextInputState::default(),
            show_filter: false,
        }
    }
}

impl ChoosePopupState {
    pub fn toggle(&mut self, idx: usize) {
        if !self.chosen_indices.remove(&idx) {
            if let Some(max) = self.max_selected {
                if self.chosen_indices.len() >= max {
                    return;
                }
            }
            self.chosen_indices.insert(idx);
        }
    }

    pub fn toggle_cursor(&mut self) {
        self.toggle(self.cursor);
    }

    pub fn toggle_selected(&mut self, items: &[(String, Style)]) {
        if let Some(orig_idx) = self.original_index(items) {
            self.toggle(orig_idx);
        }
    }

    pub fn next(&mut self, item_count: usize) {
        if item_count > 0 {
            self.cursor = (self.cursor + 1).min(item_count.saturating_sub(1));
        }
    }

    pub fn previous(&mut self) {
        self.cursor = self.cursor.saturating_sub(1);
    }

    pub fn first(&mut self) {
        self.cursor = 0;
    }

    pub fn last(&mut self, item_count: usize) {
        self.cursor = item_count.saturating_sub(1);
    }

    pub fn original_index(&self, items: &[(String, Style)]) -> Option<usize> {
        if self.show_filter && !self.text_input.content.is_empty() {
            self.filtered_indices(items).get(self.cursor).copied()
        } else {
            Some(self.cursor)
        }
    }

    pub fn visible_count(&self, items: &[(String, Style)]) -> usize {
        if self.show_filter && !self.text_input.content.is_empty() {
            self.filtered_indices(items).len()
        } else {
            items.len()
        }
    }

    pub fn filtered_indices(&self, items: &[(String, Style)]) -> Vec<usize> {
        let filter = &self.text_input.content;
        items
            .iter()
            .enumerate()
            .filter(|(_, (text, _))| {
                text.to_lowercase()
                    .contains(&filter.to_lowercase())
            })
            .map(|(i, _)| i)
            .collect()
    }

    pub fn insert_filter_char(&mut self, c: char) {
        self.text_input.insert_char(c);
    }

    pub fn delete_before_filter(&mut self) {
        self.text_input.delete_before();
    }

    pub fn delete_at_filter(&mut self) {
        self.text_input.delete_at();
    }

    pub fn filter_cursor_left(&mut self) {
        self.text_input.cursor_left();
    }

    pub fn filter_cursor_right(&mut self) {
        self.text_input.cursor_right();
    }

    pub fn filter_cursor_home(&mut self) {
        self.text_input.cursor_home();
    }

    pub fn filter_cursor_end(&mut self) {
        self.text_input.cursor_end();
    }
}

impl<'a> ChoosePopup<'a> {
    pub fn item_at(&self, state: &ChoosePopupState, popup_rect: Rect, row: u16) -> Option<usize> {
        let content_y = popup_rect.y + self.popup.content_offset();
        if row < content_y {
            return None;
        }
        let row_offset = row - content_y;
        let filter_height = self.filter_input.required_height();
        let filter_total = filter_height + self.filter_margin_bottom;
        if state.show_filter && row_offset < filter_total {
            return None;
        }
        let list_row = if state.show_filter { row_offset - filter_total } else { row_offset };
        let idx = list_row as usize + state.scroll_list_state.list_state.offset();
        if state.show_filter && !state.text_input.content.is_empty() {
            state.filtered_indices(&self.items).get(idx).copied()
        } else if idx < self.items.len() {
            Some(idx)
        } else {
            None
        }
    }

    pub fn resolve_rect(&self, area: Rect) -> Rect {
        self.popup.resolve_rect(area)
    }

    pub fn new(items: Vec<(String, Style)>) -> Self {
        Self {
            popup: Popup::new(Color::White).padding(0),
            items,
            highlight_style: DEFAULT_SELECTED,
            filter_input: TextInput::new()
                .border_style(Style::new().fg(BORDER_GRAY)),
            filter_margin_bottom: 1,
            max_selected: None,
        }
    }

    pub fn title(mut self, title: &'a str) -> Self {
        self.popup = self.popup.title(title);
        self
    }

    pub fn border_color(mut self, color: Color) -> Self {
        self.popup = self.popup.border_color(color);
        self
    }

    /// Background color for the entire popup area (including border).
    /// Delegates to `Popup::bg_color`.
    pub fn bg_color(mut self, color: Color) -> Self {
        self.popup = self.popup.bg_color(color);
        self
    }

    pub fn border_type(mut self, bt: BorderType) -> Self {
        self.popup = self.popup.border_type(bt);
        self
    }

    pub fn position(mut self, x: u16, y: u16) -> Self {
        self.popup = self.popup.position(x, y);
        self
    }

    pub fn origin(mut self, x: u16, y: u16) -> Self {
        self.popup = self.popup.origin(x, y);
        self
    }

    pub fn header(mut self) -> Self {
        self.popup = self.popup.header();
        self
    }

    pub fn width(mut self, w: PopupSize) -> Self {
        self.popup = self.popup.width(w);
        self
    }

    pub fn height(mut self, h: PopupSize) -> Self {
        self.popup = self.popup.height(h);
        self
    }

    pub fn padding(mut self, p: u16) -> Self {
        self.popup = self.popup.padding(p);
        self
    }

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

    pub fn filter_border_type(mut self, bt: BorderType) -> Self {
        self.filter_input = self.filter_input.border_type(bt);
        self
    }

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

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

    pub fn filter_bg_color(mut self, color: Color) -> Self {
        self.filter_input = self.filter_input.bg_color(color);
        self
    }

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

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

    pub fn filter_placeholder(mut self, placeholder: &'a str) -> Self {
        self.filter_input = self.filter_input.placeholder(placeholder);
        self
    }

    pub fn filter_rows(mut self, rows: u16) -> Self {
        self.filter_input = self.filter_input.rows(rows);
        self
    }

    pub fn filter_cols(mut self, cols: u16) -> Self {
        self.filter_input = self.filter_input.cols(cols);
        self
    }

    pub fn filter_scroll_padding(mut self, padding: u16) -> Self {
        self.filter_input = self.filter_input.scroll_padding(padding);
        self
    }

    pub fn filter_scroll_reserve(mut self, reserve: u16) -> Self {
        self.filter_input = self.filter_input.scroll_reserve(reserve);
        self
    }

    pub fn filter_margin_bottom(mut self, margin: u16) -> Self {
        self.filter_margin_bottom = margin;
        self
    }

    pub fn max_selected(mut self, max: usize) -> Self {
        self.max_selected = Some(max);
        self
    }

    pub fn no_max_selected(mut self) -> Self {
        self.max_selected = None;
        self
    }
}

impl StatefulWidget for ChoosePopup<'_> {
    type State = ChoosePopupState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        state.max_selected = self.max_selected;

        let mut popup = self.popup;
        if popup.get_height() == PopupSize::Auto {
            let default_h = PopupSize::Fixed((self.items.len() as u16 + 4).min(60));
            popup = popup.height(default_h);
        }
        let inner = popup.render_inner(area, buf);

        let list_area = if state.show_filter {
            let filter_height = self.filter_input.required_height();
            let vchunks = Layout::vertical([
                Constraint::Length(filter_height),
                Constraint::Length(self.filter_margin_bottom),
                Constraint::Min(0),
            ]).split(inner);

            self.filter_input.render(vchunks[0], buf, &mut state.text_input);
            vchunks[2]
        } else {
            inner
        };

        let filtered_indices = if state.show_filter && !state.text_input.content.is_empty() {
            state.filtered_indices(&self.items)
        } else {
            Vec::new()
        };

        let items: Vec<(String, Style)> = if state.show_filter && !state.text_input.content.is_empty() {
            filtered_indices
                .iter()
                .map(|&orig_idx| {
                    let prefix = if state.chosen_indices.contains(&orig_idx) {
                        ""
                    } else {
                        "  "
                    };
                    let (text, style) = &self.items[orig_idx];
                    (format!("{}{}", prefix, text), *style)
                })
                .collect()
        } else {
            self.items
                .iter()
                .enumerate()
                .map(|(i, (text, style))| {
                    let prefix = if state.chosen_indices.contains(&i) {
                        ""
                    } else {
                        "  "
                    };
                    (format!("{}{}", prefix, text), *style)
                })
                .collect()
        };

        state.scroll_list_state.follow = false;
        state.scroll_list_state.select(Some(state.cursor));
        let scroll_list = ScrollList::new_styled(items)
            .highlight_style(self.highlight_style);
        scroll_list.render(list_area, buf, &mut state.scroll_list_state);

        if let Some(idx) = state.scroll_list_state.list_state.selected() {
            state.cursor = idx;
        }
    }
}

#[cfg(test)]
mod tests;