p5 0.10.0

A tui client for Pulumi
Documentation
use ratatui::{
    buffer::Buffer,
    layout::{Margin, Rect},
    style::Style,
    widgets::{
        Block, List, ListItem, ListState, Scrollbar, ScrollbarOrientation, ScrollbarState,
        StatefulWidget,
    },
};

#[derive(Default, Clone, Debug)]
pub struct ScrollableListState {
    pub list_state: ListState,
}

pub struct ScrollableList<'a> {
    items: Vec<ListItem<'a>>,
    block: Option<Block<'a>>,
}

impl<'a> ScrollableList<'a> {
    pub fn new(items: Vec<ListItem<'a>>) -> Self {
        Self { items, block: None }
    }

    pub fn block(mut self, block: Block<'a>) -> Self {
        self.block = Some(block);
        self
    }

    fn content_height(&self) -> usize {
        self.items.iter().map(|item| item.height()).sum::<usize>()
    }

    fn v_scroll_state(&self, list_state: &ListState, area: &Rect) -> Option<ScrollbarState> {
        let content_height = self.content_height();
        let area_height = area.height as usize;
        if content_height <= area_height {
            return None;
        }
        let position = self
            .items
            .iter()
            .take(list_state.selected().unwrap_or(0))
            .map(|item| item.height())
            .sum::<usize>();

        let overflow = content_height - area_height;
        let offset_position = position.saturating_sub(overflow);

        Some(ScrollbarState::new(overflow).position(offset_position))
    }

    fn v_scroll_area(&self, area: Rect) -> Rect {
        if let Some(_block) = &self.block {
            area.inner(Margin {
                horizontal: 0,
                vertical: 1,
            })
        } else {
            area
        }
    }
}

impl StatefulWidget for ScrollableList<'_> {
    type State = ScrollableListState;

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

        let list = List::new(self.items)
            .block(self.block.unwrap_or_default())
            .highlight_style(Style::default().fg(ratatui::style::Color::Yellow));

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

        if v_scroll_state.is_none() {
            return;
        }

        let v_scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
        StatefulWidget::render(
            v_scrollbar,
            v_scroll_area,
            buf,
            &mut v_scroll_state.unwrap(),
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use insta::assert_snapshot;
    use ratatui::{Terminal, backend::TestBackend};

    #[test]
    fn test_basic_scrollable() {
        let list_items = vec![
            ListItem::new("Item 1"),
            ListItem::new("Item 2"),
            ListItem::new("Item 3"),
            ListItem::new("Item 4"),
        ];

        let list = ScrollableList::new(list_items).block(
            Block::default()
                .title("Scrollable List")
                .borders(ratatui::widgets::Borders::ALL),
        );

        let mut state = ScrollableListState {
            list_state: ListState::default(),
        };

        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
        terminal
            .draw(|frame| frame.render_stateful_widget(list, frame.area(), &mut state))
            .unwrap();
        assert_snapshot!(terminal.backend());
    }

    #[test]
    fn test_overflow() {
        let list_items = vec![
            ListItem::new("Item 1"),
            ListItem::new("Item 2"),
            ListItem::new("Item 3"),
            ListItem::new(
                "Item 4, which is a bit longer than the others to test overflow. Turns out, it still needs to be a bit longer",
            ),
        ];

        let list = ScrollableList::new(list_items).block(
            Block::default()
                .title("Scrollable List")
                .borders(ratatui::widgets::Borders::ALL),
        );

        let mut state = ScrollableListState {
            list_state: ListState::default(),
        };

        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
        terminal
            .draw(|frame| frame.render_stateful_widget(list, frame.area(), &mut state))
            .unwrap();
        assert_snapshot!(terminal.backend());
    }

    #[test]
    fn test_vertical_scroll() {
        let list_items = (1..=35)
            .map(|i| ListItem::new(format!("Item {}", i)))
            .collect::<Vec<_>>();

        let list = ScrollableList::new(list_items).block(
            Block::default()
                .title("Scrollable List")
                .borders(ratatui::widgets::Borders::ALL),
        );

        let mut state = ScrollableListState {
            list_state: ListState::default().with_selected(Some(30)),
        };

        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
        terminal
            .draw(|frame| frame.render_stateful_widget(list, frame.area(), &mut state))
            .unwrap();
        assert_snapshot!(terminal.backend());
    }

    #[test]
    fn test_vertical_scroll_first_page() {
        let list_items = (1..=35)
            .map(|i| ListItem::new(format!("Item {}", i)))
            .collect::<Vec<_>>();

        let list = ScrollableList::new(list_items).block(
            Block::default()
                .title("Scrollable List")
                .borders(ratatui::widgets::Borders::ALL),
        );

        let mut state = ScrollableListState {
            list_state: ListState::default().with_selected(Some(17)),
        };

        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
        terminal
            .draw(|frame| frame.render_stateful_widget(list, frame.area(), &mut state))
            .unwrap();
        assert_snapshot!(terminal.backend());
    }

    #[test]
    fn test_vertical_scroll_last_page() {
        let list_items = (1..=35)
            .map(|i| ListItem::new(format!("Item {}", i)))
            .collect::<Vec<_>>();

        let list = ScrollableList::new(list_items).block(
            Block::default()
                .title("Scrollable List")
                .borders(ratatui::widgets::Borders::ALL),
        );

        let mut state = ScrollableListState {
            list_state: ListState::default().with_selected(Some(34)),
        };

        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
        terminal
            .draw(|frame| frame.render_stateful_widget(list, frame.area(), &mut state))
            .unwrap();
        assert_snapshot!(terminal.backend());
    }
}