use ratatui::{
layout::Constraint,
style::Style,
widgets::{Cell, HighlightSpacing, Row, Table, TableState},
};
pub trait TableItem {
fn cells(&self) -> Vec<Cell<'static>>;
fn constraints() -> Vec<Constraint>;
fn header_cells() -> Vec<Cell<'static>> {
Self::constraints().iter().map(|_| Cell::from("")).collect()
}
fn filter_text(&self) -> String;
}
pub struct TableListStyles {
pub normal: Style,
pub selected: Style,
pub header: Style,
pub highlight_symbol: &'static str,
}
impl Default for TableListStyles {
fn default() -> Self {
Self {
normal: Style::default(),
selected: Style::default(),
highlight_symbol: "→ ",
header: Style::default(),
}
}
}
pub struct TableList<T> {
items: Vec<T>,
state: TableState,
filter: String,
filtered_indices: Vec<usize>,
}
impl<T> TableList<T> {
pub fn new(items: Vec<T>) -> Self {
let filtered_indices: Vec<usize> = (0..items.len()).collect();
let mut state = TableState::default();
if !filtered_indices.is_empty() {
state.select(Some(0));
}
Self {
items,
state,
filter: String::new(),
filtered_indices,
}
}
pub fn select_next(&mut self) {
if self.filtered_indices.is_empty() {
return;
}
let current = self.state.selected().unwrap_or(0);
let next = if current + 1 >= self.filtered_indices.len() {
0
} else {
current + 1
};
self.state.select(Some(next));
}
pub fn select_previous(&mut self) {
if self.filtered_indices.is_empty() {
return;
}
let current = self.state.selected().unwrap_or(0);
let prev = if current == 0 {
self.filtered_indices.len() - 1
} else {
current - 1
};
self.state.select(Some(prev));
}
pub fn select_first(&mut self) {
if self.filtered_indices.is_empty() {
self.state.select(None);
} else {
self.state.select(Some(0));
}
}
pub fn select_last(&mut self) {
if self.filtered_indices.is_empty() {
self.state.select(None);
} else {
self.state.select(Some(self.filtered_indices.len() - 1));
}
}
pub fn scroll_down_by(&mut self, n: u16) {
let n = n as usize;
if self.filtered_indices.is_empty() {
return;
}
let max = self.filtered_indices.len().saturating_sub(1);
let cur = self.state.selected().unwrap_or(0);
let next = (cur + n).min(max);
self.state.select(Some(next));
}
pub fn scroll_up_by(&mut self, n: u16) {
let n = n as usize;
let cur = self.state.selected().unwrap_or(0);
let prev = cur.saturating_sub(n);
self.state.select(Some(prev));
}
pub fn selected(&self) -> Option<&T> {
let idx = *self
.state
.selected()
.and_then(|i| self.filtered_indices.get(i))?;
self.items.get(idx)
}
pub fn selected_mut(&mut self) -> Option<&mut T> {
let idx = *self
.state
.selected()
.and_then(|i| self.filtered_indices.get(i))?;
self.items.get_mut(idx)
}
pub fn selected_index(&self) -> Option<usize> {
let i = self.state.selected()?;
self.filtered_indices.get(i).copied()
}
pub fn len(&self) -> usize {
self.filtered_indices.len()
}
pub fn is_empty(&self) -> bool {
self.filtered_indices.is_empty()
}
pub fn state_mut(&mut self) -> &mut TableState {
&mut self.state
}
pub fn filter_text(&self) -> &str {
&self.filter
}
pub fn clear_filter(&mut self) {
self.filter.clear();
self.filtered_indices = (0..self.items.len()).collect();
if !self.filtered_indices.is_empty() {
self.state.select(Some(0));
} else {
self.state.select(None);
}
}
}
impl<T: TableItem> TableList<T> {
pub fn set_filter(&mut self, filter: &str) {
self.filter = filter.to_owned();
let lower = self.filter.to_lowercase();
self.filtered_indices = self
.items
.iter()
.enumerate()
.filter(|(_, item)| item.filter_text().to_lowercase().contains(&lower))
.map(|(i, _)| i)
.collect();
if !self.filtered_indices.is_empty() {
self.state.select(Some(0));
} else {
self.state.select(None);
}
}
pub fn filter_input(&mut self, c: char) {
self.filter.push(c);
let f = self.filter.clone();
self.set_filter(&f);
}
pub fn filter_backspace(&mut self) {
self.filter.pop();
let f = self.filter.clone();
self.set_filter(&f);
}
pub fn build_widget(&self, styles: &TableListStyles) -> Table<'_> {
let constraints = T::constraints();
let header = Row::new(
T::header_cells()
.into_iter()
.map(|c| c.style(styles.header))
.collect::<Vec<_>>(),
);
let rows: Vec<Row<'_>> = self
.filtered_indices
.iter()
.filter_map(|&idx| self.items.get(idx))
.map(|item| Row::new(item.cells().into_iter().map(|c| c.style(styles.normal))))
.collect();
Table::new(rows, constraints)
.header(header)
.row_highlight_style(styles.selected)
.highlight_symbol(styles.highlight_symbol)
.highlight_spacing(HighlightSpacing::Always)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq)]
struct TestItem {
name: String,
value: u32,
}
impl TableItem for TestItem {
fn cells(&self) -> Vec<Cell<'static>> {
vec![
Cell::from(self.name.clone()),
Cell::from(self.value.to_string()),
]
}
fn constraints() -> Vec<Constraint> {
vec![Constraint::Min(10), Constraint::Length(6)]
}
fn header_cells() -> Vec<Cell<'static>> {
vec![Cell::from("Name"), Cell::from("Value")]
}
fn filter_text(&self) -> String {
self.name.clone()
}
}
fn sample_items() -> Vec<TestItem> {
vec![
TestItem {
name: "alpha".into(),
value: 10,
},
TestItem {
name: "beta".into(),
value: 20,
},
TestItem {
name: "gamma".into(),
value: 30,
},
]
}
#[test]
fn new_selects_first() {
let list = TableList::new(sample_items());
assert_eq!(list.selected().unwrap().name, "alpha");
assert_eq!(list.selected_index(), Some(0));
}
#[test]
fn navigation() {
let mut list = TableList::new(sample_items());
list.select_next();
assert_eq!(list.selected().unwrap().name, "beta");
list.select_last();
assert_eq!(list.selected().unwrap().name, "gamma");
list.select_previous();
assert_eq!(list.selected().unwrap().name, "beta");
list.select_first();
assert_eq!(list.selected().unwrap().name, "alpha");
}
#[test]
fn scroll_by() {
let mut list = TableList::new(sample_items());
list.scroll_down_by(2);
assert_eq!(list.selected().unwrap().name, "gamma");
list.scroll_up_by(1);
assert_eq!(list.selected().unwrap().name, "beta");
}
#[test]
fn filter() {
let mut list = TableList::new(sample_items());
list.set_filter("al");
assert_eq!(list.len(), 1); assert_eq!(list.selected().unwrap().name, "alpha");
}
#[test]
fn filter_input_and_backspace() {
let mut list = TableList::new(sample_items());
list.filter_input('b');
assert_eq!(list.len(), 1);
assert_eq!(list.selected().unwrap().name, "beta");
list.filter_backspace();
assert_eq!(list.len(), 3); }
#[test]
fn clear_filter() {
let mut list = TableList::new(sample_items());
list.set_filter("zzz");
assert!(list.is_empty());
list.clear_filter();
assert_eq!(list.len(), 3);
assert_eq!(list.selected().unwrap().name, "alpha");
}
#[test]
fn empty_list() {
let list: TableList<TestItem> = TableList::new(vec![]);
assert!(list.is_empty());
assert_eq!(list.selected(), None);
}
#[test]
fn build_widget() {
let list = TableList::new(sample_items());
let styles = TableListStyles::default();
let _table = list.build_widget(&styles);
}
#[test]
fn wrap_around_navigation() {
let mut list = TableList::new(sample_items());
list.select_next();
list.select_next();
list.select_next(); assert_eq!(list.selected().unwrap().name, "alpha");
list.select_previous(); assert_eq!(list.selected().unwrap().name, "gamma");
}
}