use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Widget, Wrap},
};
#[derive(Debug, Clone, Default)]
pub struct ListPickerState {
pub selected_index: usize,
pub scroll: u16,
pub total_items: usize,
}
impl ListPickerState {
pub fn new(total_items: usize) -> Self {
Self {
selected_index: 0,
scroll: 0,
total_items,
}
}
pub fn select_prev(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
pub fn select_next(&mut self) {
if self.selected_index + 1 < self.total_items {
self.selected_index += 1;
}
}
pub fn select(&mut self, index: usize) {
if index < self.total_items {
self.selected_index = index;
}
}
pub fn select_first(&mut self) {
self.selected_index = 0;
}
pub fn select_last(&mut self) {
if self.total_items > 0 {
self.selected_index = self.total_items - 1;
}
}
pub fn ensure_visible(&mut self, viewport_height: usize) {
if viewport_height == 0 {
return;
}
if self.selected_index < self.scroll as usize {
self.scroll = self.selected_index as u16;
} else if self.selected_index >= self.scroll as usize + viewport_height {
self.scroll = (self.selected_index - viewport_height + 1) as u16;
}
}
pub fn set_total(&mut self, total: usize) {
self.total_items = total;
if self.selected_index >= total && total > 0 {
self.selected_index = total - 1;
}
}
}
#[derive(Debug, Clone)]
pub struct ListPickerStyle {
pub selected_style: Style,
pub normal_style: Style,
pub indicator_style: Style,
pub border_style: Style,
pub indicator: &'static str,
pub indicator_empty: &'static str,
pub bordered: bool,
}
impl Default for ListPickerStyle {
fn default() -> Self {
Self {
selected_style: Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
normal_style: Style::default().fg(Color::White),
indicator_style: Style::default().fg(Color::Yellow),
border_style: Style::default().fg(Color::Cyan),
indicator: "▶ ",
indicator_empty: " ",
bordered: true,
}
}
}
impl From<&crate::theme::Theme> for ListPickerStyle {
fn from(theme: &crate::theme::Theme) -> Self {
let p = &theme.palette;
Self {
selected_style: Style::default().fg(p.primary).add_modifier(Modifier::BOLD),
normal_style: Style::default().fg(p.text),
indicator_style: Style::default().fg(p.primary),
border_style: Style::default().fg(p.border_accent),
indicator: "▶ ",
indicator_empty: " ",
bordered: true,
}
}
}
impl ListPickerStyle {
pub fn arrow() -> Self {
Self::default()
}
pub fn bracket() -> Self {
Self {
indicator: "> ",
indicator_empty: " ",
..Default::default()
}
}
pub fn checkbox() -> Self {
Self {
indicator: "[x] ",
indicator_empty: "[ ] ",
selected_style: Style::default().fg(Color::Green),
..Default::default()
}
}
pub fn bordered(mut self, bordered: bool) -> Self {
self.bordered = bordered;
self
}
}
type DefaultRenderFn<T> = fn(&T, usize, bool) -> Vec<Line<'static>>;
pub struct ListPicker<'a, T, F = DefaultRenderFn<T>>
where
F: Fn(&T, usize, bool) -> Vec<Line<'static>>,
{
items: &'a [T],
state: &'a ListPickerState,
style: ListPickerStyle,
title: Option<&'a str>,
footer: Option<Vec<Line<'static>>>,
render_fn: F,
}
impl<'a, T: std::fmt::Display> ListPicker<'a, T, DefaultRenderFn<T>> {
pub fn new(items: &'a [T], state: &'a ListPickerState) -> Self {
Self {
items,
state,
style: ListPickerStyle::default(),
title: None,
footer: None,
render_fn: |item, _idx, _selected| vec![Line::from(item.to_string())],
}
}
}
impl<'a, T, F> ListPicker<'a, T, F>
where
F: Fn(&T, usize, bool) -> Vec<Line<'static>>,
{
pub fn render_item<G>(self, render_fn: G) -> ListPicker<'a, T, G>
where
G: Fn(&T, usize, bool) -> Vec<Line<'static>>,
{
ListPicker {
items: self.items,
state: self.state,
style: self.style,
title: self.title,
footer: self.footer,
render_fn,
}
}
pub fn title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
pub fn footer(mut self, footer: Vec<Line<'static>>) -> Self {
self.footer = Some(footer);
self
}
pub fn style(mut self, style: ListPickerStyle) -> Self {
self.style = style;
self
}
pub fn theme(self, theme: &crate::theme::Theme) -> Self {
self.style(ListPickerStyle::from(theme))
}
fn build_lines(&self, _area: Rect, inner_height: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if let Some(title) = self.title {
lines.push(Line::from(vec![Span::styled(
title.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]));
lines.push(Line::from("")); }
let header_lines = if self.title.is_some() { 2 } else { 0 };
let footer_lines = self.footer.as_ref().map(|f| f.len()).unwrap_or(0);
let available_height = inner_height as usize - header_lines - footer_lines;
if self.items.is_empty() {
lines.push(Line::from(vec![Span::styled(
"No items",
Style::default().fg(Color::Gray),
)]));
} else {
let scroll = self.state.scroll as usize;
for (idx, item) in self
.items
.iter()
.enumerate()
.skip(scroll)
.take(available_height)
{
let is_selected = idx == self.state.selected_index;
let indicator = if is_selected {
self.style.indicator
} else {
self.style.indicator_empty
};
let item_style = if is_selected {
self.style.selected_style
} else {
self.style.normal_style
};
let item_lines = (self.render_fn)(item, idx, is_selected);
for (line_idx, line) in item_lines.into_iter().enumerate() {
let mut spans = Vec::new();
if line_idx == 0 {
spans.push(Span::styled(
indicator.to_string(),
self.style.indicator_style,
));
} else {
spans.push(Span::raw(" ".repeat(self.style.indicator.len())));
}
for span in line.spans {
spans.push(Span::styled(span.content.to_string(), item_style));
}
lines.push(Line::from(spans));
}
}
}
if let Some(footer) = &self.footer {
for line in footer {
lines.push(line.clone());
}
}
lines
}
}
impl<'a, T, F> Widget for ListPicker<'a, T, F>
where
F: Fn(&T, usize, bool) -> Vec<Line<'static>>,
{
fn render(self, area: Rect, buf: &mut Buffer) {
let block = if self.style.bordered {
Some(
Block::default()
.borders(Borders::ALL)
.border_style(self.style.border_style),
)
} else {
None
};
let inner = if let Some(ref block) = block {
block.inner(area)
} else {
area
};
if let Some(block) = block {
block.render(area, buf);
}
let lines = self.build_lines(area, inner.height);
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
paragraph.render(inner, buf);
}
}
pub fn key_hints_footer(hints: &[(&str, &str)]) -> Vec<Line<'static>> {
let mut spans = Vec::new();
for (idx, (key, desc)) in hints.iter().enumerate() {
if idx > 0 {
spans.push(Span::raw(" | "));
}
spans.push(Span::styled(
key.to_string(),
Style::default().fg(Color::Green),
));
spans.push(Span::raw(format!(": {}", desc)));
}
vec![Line::from(""), Line::from(spans)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_state_new() {
let state = ListPickerState::new(10);
assert_eq!(state.selected_index, 0);
assert_eq!(state.scroll, 0);
assert_eq!(state.total_items, 10);
}
#[test]
fn test_state_navigation() {
let mut state = ListPickerState::new(5);
assert_eq!(state.selected_index, 0);
state.select_next();
assert_eq!(state.selected_index, 1);
state.select_prev();
assert_eq!(state.selected_index, 0);
state.select_prev(); assert_eq!(state.selected_index, 0);
state.select_last();
assert_eq!(state.selected_index, 4);
state.select_next(); assert_eq!(state.selected_index, 4);
}
#[test]
fn test_select_first_and_last() {
let mut state = ListPickerState::new(10);
state.selected_index = 5;
state.select_first();
assert_eq!(state.selected_index, 0);
state.select_last();
assert_eq!(state.selected_index, 9);
}
#[test]
fn test_select_specific_index() {
let mut state = ListPickerState::new(10);
state.select(5);
assert_eq!(state.selected_index, 5);
state.select(100);
assert_eq!(state.selected_index, 5); }
#[test]
fn test_ensure_visible() {
let mut state = ListPickerState::new(20);
state.selected_index = 15;
state.ensure_visible(10);
assert!(state.scroll >= 6); }
#[test]
fn test_ensure_visible_scroll_up() {
let mut state = ListPickerState::new(20);
state.scroll = 10;
state.selected_index = 5;
state.ensure_visible(10);
assert_eq!(state.scroll, 5);
}
#[test]
fn test_ensure_visible_zero_viewport() {
let mut state = ListPickerState::new(20);
state.selected_index = 10;
state.scroll = 5;
state.ensure_visible(0);
assert_eq!(state.scroll, 5);
}
#[test]
fn test_set_total() {
let mut state = ListPickerState::new(10);
state.selected_index = 8;
state.set_total(5);
assert_eq!(state.total_items, 5);
assert_eq!(state.selected_index, 4);
state.set_total(20);
assert_eq!(state.total_items, 20);
assert_eq!(state.selected_index, 4); }
#[test]
fn test_empty_list() {
let mut state = ListPickerState::new(0);
state.select_next();
assert_eq!(state.selected_index, 0);
state.select_last();
assert_eq!(state.selected_index, 0);
}
#[test]
fn test_list_picker_render() {
let items = vec!["Item 1", "Item 2", "Item 3"];
let state = ListPickerState::new(items.len());
let picker = ListPicker::new(&items, &state).title("Test");
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
picker.render(Rect::new(0, 0, 40, 10), &mut buf);
}
#[test]
fn test_list_picker_with_custom_render() {
let items = vec!["A", "B", "C"];
let state = ListPickerState::new(items.len());
let picker = ListPicker::new(&items, &state).render_item(|item, idx, selected| {
let prefix = if selected { "> " } else { " " };
vec![Line::from(format!("{}{}. {}", prefix, idx + 1, item))]
});
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
picker.render(Rect::new(0, 0, 40, 10), &mut buf);
}
#[test]
fn test_list_picker_styles() {
let arrow = ListPickerStyle::arrow();
assert_eq!(arrow.indicator, "▶ ");
let bracket = ListPickerStyle::bracket();
assert_eq!(bracket.indicator, "> ");
let checkbox = ListPickerStyle::checkbox();
assert_eq!(checkbox.indicator, "[x] ");
assert_eq!(checkbox.indicator_empty, "[ ] ");
}
#[test]
fn test_list_picker_style_bordered() {
let style = ListPickerStyle::default().bordered(false);
assert!(!style.bordered);
let style = ListPickerStyle::default().bordered(true);
assert!(style.bordered);
}
#[test]
fn test_key_hints_footer() {
let footer = key_hints_footer(&[("↑↓", "Navigate"), ("Enter", "Select")]);
assert_eq!(footer.len(), 2);
}
#[test]
fn test_key_hints_footer_empty() {
let footer = key_hints_footer(&[]);
assert_eq!(footer.len(), 2); }
}