mod render;
mod state;
mod types;
pub use types::{
Column, SortComparator, SortDirection, TableMessage, TableOutput, TableRow, date_comparator,
numeric_comparator,
};
use std::marker::PhantomData;
use ratatui::prelude::*;
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
use crate::scroll::ScrollState;
use crate::theme::Theme;
const MIN_COLUMN_WIDTH: u16 = 3;
#[derive(Clone, Debug)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct TableState<T: TableRow> {
rows: Vec<T>,
columns: Vec<Column>,
selected: Option<usize>,
sort_columns: Vec<(usize, SortDirection)>,
display_order: Vec<usize>,
filter_text: String,
#[cfg_attr(feature = "serialization", serde(skip))]
scroll: ScrollState,
}
impl<T: TableRow + PartialEq> PartialEq for TableState<T> {
fn eq(&self, other: &Self) -> bool {
self.rows == other.rows
&& self.columns == other.columns
&& self.selected == other.selected
&& self.sort_columns == other.sort_columns
&& self.display_order == other.display_order
&& self.filter_text == other.filter_text
}
}
impl<T: TableRow> Default for TableState<T> {
fn default() -> Self {
Self {
rows: Vec::new(),
columns: Vec::new(),
selected: None,
sort_columns: Vec::new(),
display_order: Vec::new(),
filter_text: String::new(),
scroll: ScrollState::default(),
}
}
}
pub struct Table<T: TableRow>(PhantomData<T>);
impl<T: TableRow + 'static> Component for Table<T> {
type State = TableState<T>;
type Message = TableMessage;
type Output = TableOutput<T>;
fn init() -> Self::State {
TableState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
TableMessage::SetFilter(ref text) => {
state.set_filter_text(text);
return Some(TableOutput::FilterChanged(text.clone()));
}
TableMessage::ClearFilter => {
state.clear_filter();
return Some(TableOutput::FilterChanged(String::new()));
}
_ => {}
}
if state.display_order.is_empty() {
return None;
}
let len = state.display_order.len();
let current = state.selected.unwrap_or(0);
match msg {
TableMessage::Up => {
if current > 0 {
let new_index = current - 1;
state.selected = Some(new_index);
return Some(TableOutput::SelectionChanged(new_index));
}
}
TableMessage::Down => {
if current < len - 1 {
let new_index = current + 1;
state.selected = Some(new_index);
return Some(TableOutput::SelectionChanged(new_index));
}
}
TableMessage::First => {
if current != 0 {
state.selected = Some(0);
return Some(TableOutput::SelectionChanged(0));
}
}
TableMessage::Last => {
let last = len - 1;
if current != last {
state.selected = Some(last);
return Some(TableOutput::SelectionChanged(last));
}
}
TableMessage::PageUp(page_size) => {
let new_index = current.saturating_sub(page_size);
if new_index != current {
state.selected = Some(new_index);
return Some(TableOutput::SelectionChanged(new_index));
}
}
TableMessage::PageDown(page_size) => {
let new_index = (current + page_size).min(len - 1);
if new_index != current {
state.selected = Some(new_index);
return Some(TableOutput::SelectionChanged(new_index));
}
}
TableMessage::Select => {
if let Some(row) = state.selected_row().cloned() {
return Some(TableOutput::Selected(row));
}
}
TableMessage::SortBy(col) => {
if let Some(column) = state.columns.get(col) {
if !column.is_sortable() {
return None;
}
let primary = state.sort_columns.first().copied();
match primary {
Some((c, SortDirection::Ascending)) if c == col => {
state.sort_columns = vec![(col, SortDirection::Descending)];
state.rebuild_display_order();
return Some(TableOutput::Sorted {
column: col,
direction: SortDirection::Descending,
});
}
Some((c, SortDirection::Descending)) if c == col => {
state.sort_columns.clear();
state.rebuild_display_order();
return Some(TableOutput::SortCleared);
}
_ => {
state.sort_columns = vec![(col, SortDirection::Ascending)];
state.rebuild_display_order();
return Some(TableOutput::Sorted {
column: col,
direction: SortDirection::Ascending,
});
}
}
}
}
TableMessage::AddSort(col) => {
if let Some(column) = state.columns.get(col) {
if !column.is_sortable() {
return None;
}
if let Some(pos) = state.sort_columns.iter().position(|&(c, _)| c == col) {
let (_, dir) = state.sort_columns[pos];
let new_dir = dir.toggle();
state.sort_columns[pos] = (col, new_dir);
state.rebuild_display_order();
return Some(TableOutput::Sorted {
column: col,
direction: new_dir,
});
}
state.sort_columns.push((col, SortDirection::Ascending));
state.rebuild_display_order();
return Some(TableOutput::Sorted {
column: col,
direction: SortDirection::Ascending,
});
}
}
TableMessage::ClearSort => {
if !state.sort_columns.is_empty() {
state.sort_columns.clear();
state.rebuild_display_order();
return Some(TableOutput::SortCleared);
}
}
TableMessage::IncreaseColumnWidth(col) => {
if let Some(column) = state.columns.get_mut(col) {
if let Constraint::Length(w) = column.width() {
let new_width = w.saturating_add(1);
column.set_width(Constraint::Length(new_width));
return Some(TableOutput::ColumnResized {
column: col,
width: new_width,
});
}
}
}
TableMessage::DecreaseColumnWidth(col) => {
if let Some(column) = state.columns.get_mut(col) {
if let Constraint::Length(w) = column.width() {
let new_width = w.saturating_sub(1).max(MIN_COLUMN_WIDTH);
if new_width != w {
column.set_width(Constraint::Length(new_width));
return Some(TableOutput::ColumnResized {
column: col,
width: new_width,
});
}
}
}
}
TableMessage::SetFilter(_) | TableMessage::ClearFilter => {
unreachable!("handled above")
}
}
None
}
fn handle_event(
state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
let has_shift = key.modifiers.shift();
match key.code {
Key::Up | Key::Char('k') => Some(TableMessage::Up),
Key::Down | Key::Char('j') => Some(TableMessage::Down),
Key::Home => Some(TableMessage::First),
Key::End => Some(TableMessage::Last),
Key::Enter if has_shift => {
None
}
Key::Enter => Some(TableMessage::Select),
Key::Char('+') => {
let col = state.sort_columns.first().map(|&(c, _)| c).unwrap_or(0);
Some(TableMessage::IncreaseColumnWidth(col))
}
Key::Char('-') => {
let col = state.sort_columns.first().map(|&(c, _)| c).unwrap_or(0);
Some(TableMessage::DecreaseColumnWidth(col))
}
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
render::render_table(
state,
ctx.frame,
ctx.area,
ctx.theme,
ctx.focused,
ctx.disabled,
);
}
}
#[cfg(test)]
mod filter_tests;
#[cfg(test)]
mod multi_sort_tests;
#[cfg(test)]
mod resize_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod view_tests;