oo-ide 0.0.4

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! Generic scrollable selectable list widget.
//!
//! `SelectableList<T>` owns its items and cursor position.  Rendering is
//! driven by a caller-supplied closure `(&T, bool) -> ListItem` so the widget
//! stays fully generic — it knows nothing about what it displays.
//!
//! # Example
//! ```ignore
//! let mut list = SelectableList::new(vec!["foo", "bar", "baz"]);
//! list.move_down();
//! frame.render_widget(
//!     list.widget(|item, selected| {
//!         ListItem::new(*item).style(if selected { HIGHLIGHT } else { NORMAL })
//!     }),
//!     area,
//! );
//! ```

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Style},
    text::Span,
    widgets::{List, ListItem, ListState, StatefulWidget, Widget},
};

/// A scrollable, cursor-tracked list of items of type `T`.
///
/// Rendering requires a `render_item` closure; everything else (cursor
/// movement, scroll calculation, empty-state placeholder) is handled here.
#[derive(Debug)]
pub struct SelectableList<T> {
    items: Vec<T>,
    cursor: usize,
}

impl<T> SelectableList<T> {
    pub fn new(items: Vec<T>) -> Self {
        Self { items, cursor: 0 }
    }

    /// Replace the item list and reset the cursor.
    pub fn set_items(&mut self, items: Vec<T>) {
        self.items = items;
        self.cursor = 0;
    }

    #[allow(dead_code)]
    pub fn items(&self) -> &[T] {
        &self.items
    }

    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    #[allow(dead_code)]
    pub fn cursor(&self) -> usize {
        self.cursor
    }

    /// Return a reference to the currently selected item, if any.
    pub fn selected(&self) -> Option<&T> {
        self.items.get(self.cursor)
    }

    pub fn move_up(&mut self) {
        if self.cursor > 0 {
            self.cursor -= 1;
        }
    }

    pub fn move_down(&mut self) {
        if self.cursor + 1 < self.items.len() {
            self.cursor += 1;
        }
    }

    /// Build a renderable [`SelectableListWidget`] for this frame.
    ///
    /// `render_item(item, is_selected) -> ListItem` is called once per visible
    /// row; you control all styling.
    pub fn widget<F>(&self, render_item: F) -> SelectableListWidget<'_, T, F>
    where
        F: Fn(&T, bool) -> ListItem<'static>,
    {
        SelectableListWidget {
            list: self,
            render_item,
        }
    }
}

/// The stateless view produced by [`SelectableList::widget`].
/// Implements ratatui's [`Widget`] so it can be passed directly to
/// `Frame::render_widget`.
pub struct SelectableListWidget<'a, T, F> {
    list: &'a SelectableList<T>,
    render_item: F,
}

impl<T, F> Widget for SelectableListWidget<'_, T, F>
where
    F: Fn(&T, bool) -> ListItem<'static>,
{
    fn render(self, area: Rect, buf: &mut Buffer) {
        let height = area.height as usize;
        let cursor = self.list.cursor;

        if self.list.items.is_empty() {
            Widget::render(
                List::new(vec![ListItem::new(Span::styled(
                    "  (empty)",
                    Style::default().fg(Color::DarkGray),
                ))]),
                area,
                buf,
            );
            return;
        }

        let scroll = if cursor >= height {
            cursor + 1 - height
        } else {
            0
        };

        let items: Vec<ListItem<'static>> = self
            .list
            .items
            .iter()
            .enumerate()
            .skip(scroll)
            .take(height)
            .map(|(i, item)| (self.render_item)(item, i == cursor))
            .collect();

        let mut state = ListState::default();
        state.select(Some(cursor.saturating_sub(scroll)));
        StatefulWidget::render(List::new(items), area, buf, &mut state);
    }
}