use ratatui::{
buffer::Buffer,
layout::Rect,
widgets::{List, ListItem, ListState, StatefulWidget},
};
use crate::tui::theme;
pub trait ListItemRenderable {
fn to_list_item(&self) -> ListItem<'_>;
}
pub struct SelectableList<'a, T> {
items: &'a [T],
title: &'a str,
focused: bool,
}
impl<'a, T> SelectableList<'a, T> {
#[must_use]
pub fn new(items: &'a [T], title: &'a str) -> Self {
Self {
items,
title,
focused: false,
}
}
#[must_use]
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
}
impl<T: ListItemRenderable> StatefulWidget for SelectableList<'_, T> {
type State = ListState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let items: Vec<ListItem<'_>> = self
.items
.iter()
.map(ListItemRenderable::to_list_item)
.collect();
let list = List::new(items)
.block(theme::block(
self.title,
if self.focused {
theme::BlockVariant::Focused
} else {
theme::BlockVariant::Unfocused
},
))
.highlight_style(theme::highlight_style(self.focused))
.highlight_symbol(theme::highlight_symbol(self.focused));
StatefulWidget::render(list, area, buf, state);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::test_utils::{buffer_to_text, mock_list_state};
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
fn render_stateful_to_text<W, S>(widget: W, state: &mut S, width: u16, height: u16) -> String
where
W: StatefulWidget<State = S>,
{
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf, state);
buffer_to_text(&buf)
}
struct TestItem {
name: String,
color: Color,
}
impl TestItem {
fn new(name: &str, color: Color) -> Self {
Self {
name: name.to_string(),
color,
}
}
}
impl ListItemRenderable for TestItem {
fn to_list_item(&self) -> ListItem<'_> {
ListItem::new(Line::from(vec![
Span::styled("● ", Style::default().fg(self.color)),
Span::raw(&self.name),
]))
}
}
#[test]
fn renders_empty_list() {
let items: Vec<TestItem> = vec![];
let list = SelectableList::new(&items, "Empty List").focused(true);
let mut state = mock_list_state(None);
let text = render_stateful_to_text(list, &mut state, 40, 5);
assert!(text.contains("Empty List"), "Text:\n{text}");
}
#[test]
fn renders_items_with_colors() {
let items = vec![
TestItem::new("Item 1", Color::Green),
TestItem::new("Item 2", Color::Red),
];
let list = SelectableList::new(&items, "Test List").focused(true);
let mut state = mock_list_state(Some(0));
let text = render_stateful_to_text(list, &mut state, 40, 6);
assert!(text.contains("Item 1"), "Text:\n{text}");
assert!(text.contains("Item 2"), "Text:\n{text}");
}
#[test]
fn focused_list_shows_highlight_symbol() {
let items = vec![TestItem::new("Selected", Color::Green)];
let list = SelectableList::new(&items, "Focused").focused(true);
let mut state = mock_list_state(Some(0));
let text = render_stateful_to_text(list, &mut state, 40, 5);
assert!(
text.contains("> "),
"Expected highlight symbol in focused list:\n{text}"
);
}
#[test]
fn unfocused_list_hides_highlight_symbol() {
let items = vec![TestItem::new("Selected", Color::Green)];
let list = SelectableList::new(&items, "Unfocused").focused(false);
let mut state = mock_list_state(Some(0));
let text = render_stateful_to_text(list, &mut state, 40, 5);
assert!(
!text.contains("> ●"),
"Unfocused list should not show highlight symbol:\n{text}"
);
}
#[test]
fn list_title_appears_in_border() {
let items = vec![TestItem::new("Test", Color::White)];
let list = SelectableList::new(&items, "Custom Title").focused(false);
let mut state = mock_list_state(None);
let text = render_stateful_to_text(list, &mut state, 40, 5);
assert!(text.contains("Custom Title"), "Text:\n{text}");
}
}