mirror of
https://github.com/ClementTsang/bottom.git
synced 2026-05-04 14:00:38 +00:00
bb0d4bdd32
* refactor: refactor the data table increment position code * some comments * changelog
299 lines
10 KiB
Rust
299 lines
10 KiB
Rust
pub mod column;
|
|
pub mod data_type;
|
|
pub mod draw;
|
|
pub mod props;
|
|
pub mod sortable;
|
|
pub mod state;
|
|
pub mod styling;
|
|
|
|
use std::{convert::TryInto, marker::PhantomData};
|
|
|
|
pub use column::*;
|
|
pub use data_type::*;
|
|
pub use draw::*;
|
|
pub use props::DataTableProps;
|
|
pub use sortable::*;
|
|
pub use state::{DataTableState, ScrollDirection};
|
|
pub use styling::*;
|
|
|
|
use crate::utils::general::ClampExt;
|
|
|
|
/// A [`DataTable`] is a component that displays data in a tabular form.
|
|
///
|
|
/// Note that [`DataTable`] takes a generic type `S`, bounded by [`SortType`].
|
|
/// This controls whether this table expects sorted data or not, with two
|
|
/// expected types:
|
|
///
|
|
/// - [`Unsortable`]: The default if otherwise not specified. This table does
|
|
/// not expect sorted data.
|
|
/// - [`Sortable`]: This table expects sorted data, and there are helper
|
|
/// functions to facilitate things like sorting based on a selected column,
|
|
/// shortcut column selection support, mouse column selection support, etc.
|
|
///
|
|
/// FIXME: We already do all the text width checks - can we skip the underlying ones?
|
|
pub struct DataTable<DataType, Header, S = Unsortable, C = Column<Header>> {
|
|
pub columns: Vec<C>,
|
|
pub state: DataTableState,
|
|
pub props: DataTableProps,
|
|
pub styling: DataTableStyling,
|
|
data: Vec<DataType>,
|
|
sort_type: S,
|
|
first_draw: bool,
|
|
first_index: Option<usize>,
|
|
_pd: PhantomData<(DataType, S, Header)>,
|
|
}
|
|
|
|
impl<DataType: DataToCell<H>, H: ColumnHeader> DataTable<DataType, H, Unsortable, Column<H>> {
|
|
pub fn new<C: Into<Vec<Column<H>>>>(
|
|
columns: C, props: DataTableProps, styling: DataTableStyling,
|
|
) -> Self {
|
|
Self {
|
|
columns: columns.into(),
|
|
state: DataTableState::default(),
|
|
props,
|
|
styling,
|
|
data: vec![],
|
|
sort_type: Unsortable,
|
|
first_draw: true,
|
|
first_index: None,
|
|
_pd: PhantomData,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<DataType: DataToCell<H>, H: ColumnHeader, S: SortType, C: DataTableColumn<H>>
|
|
DataTable<DataType, H, S, C>
|
|
{
|
|
/// Sets the default value selected on first initialization, if possible.
|
|
pub fn first_draw_index(mut self, first_index: usize) -> Self {
|
|
self.first_index = Some(first_index);
|
|
self
|
|
}
|
|
|
|
/// Sets the scroll position to the first value.
|
|
pub fn scroll_to_first(&mut self) {
|
|
self.state.current_index = 0;
|
|
self.state.scroll_direction = ScrollDirection::Up;
|
|
}
|
|
|
|
/// Sets the scroll position to the last value.
|
|
pub fn scroll_to_last(&mut self) {
|
|
self.state.current_index = self.data.len().saturating_sub(1);
|
|
self.state.scroll_direction = ScrollDirection::Down;
|
|
}
|
|
|
|
/// Updates the scroll position to be valid for the number of entries.
|
|
pub fn set_data(&mut self, data: Vec<DataType>) {
|
|
self.data = data;
|
|
let max_pos = self.data.len().saturating_sub(1);
|
|
if self.state.current_index > max_pos {
|
|
self.state.current_index = max_pos;
|
|
self.state.display_start_index = 0;
|
|
self.state.scroll_direction = ScrollDirection::Down;
|
|
}
|
|
}
|
|
|
|
/// Increments the scroll position if possible by a positive/negative
|
|
/// offset. If there is a valid change, this function will also return
|
|
/// the new position wrapped in an [`Option`].
|
|
///
|
|
/// Note that despite the name, this handles both incrementing (positive
|
|
/// change) and decrementing (negative change).
|
|
pub fn increment_position(&mut self, change: i64) -> Option<usize> {
|
|
let num_entries = self.data.len();
|
|
|
|
if num_entries == 0 {
|
|
return None;
|
|
}
|
|
|
|
let Ok(current_index): Result<i64, _> = self.state.current_index.try_into() else {
|
|
return None;
|
|
};
|
|
|
|
// We do this to clamp the proposed index to 0 if the change is greater
|
|
// than the number of entries left from the current index. This gives
|
|
// a more intuitive behaviour when using things like page up/down.
|
|
let proposed = current_index + change;
|
|
|
|
// We check num_entries > 0 above.
|
|
self.state.current_index = proposed.clamp(0, (num_entries - 1) as i64) as usize;
|
|
|
|
self.state.scroll_direction = if change < 0 {
|
|
ScrollDirection::Up
|
|
} else {
|
|
ScrollDirection::Down
|
|
};
|
|
|
|
Some(self.state.current_index)
|
|
}
|
|
|
|
/// Updates the scroll position to a selected index.
|
|
#[expect(clippy::comparison_chain)]
|
|
pub fn set_position(&mut self, new_index: usize) {
|
|
let new_index = new_index.clamp_upper(self.data.len().saturating_sub(1));
|
|
if self.state.current_index < new_index {
|
|
self.state.scroll_direction = ScrollDirection::Down;
|
|
} else if self.state.current_index > new_index {
|
|
self.state.scroll_direction = ScrollDirection::Up;
|
|
}
|
|
self.state.current_index = new_index;
|
|
}
|
|
|
|
/// Returns the current scroll index.
|
|
pub fn current_index(&self) -> usize {
|
|
self.state.current_index
|
|
}
|
|
|
|
/// Optionally returns the currently selected item, if there is one.
|
|
pub fn current_item(&self) -> Option<&DataType> {
|
|
self.data.get(self.state.current_index)
|
|
}
|
|
|
|
/// Returns ratatui's internal selection.
|
|
pub fn ratatui_selected(&self) -> Option<usize> {
|
|
self.state.table_state.selected()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use std::{borrow::Cow, num::NonZeroU16};
|
|
|
|
use super::*;
|
|
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
struct TestType {
|
|
index: usize,
|
|
}
|
|
|
|
impl DataToCell<&'static str> for TestType {
|
|
fn to_cell(
|
|
&self, _column: &&'static str, _calculated_width: NonZeroU16,
|
|
) -> Option<Cow<'static, str>> {
|
|
None
|
|
}
|
|
|
|
fn column_widths<C: DataTableColumn<&'static str>>(
|
|
_data: &[Self], _columns: &[C],
|
|
) -> Vec<u16>
|
|
where
|
|
Self: Sized,
|
|
{
|
|
vec![]
|
|
}
|
|
}
|
|
|
|
fn create_test_table() -> DataTable<TestType, &'static str> {
|
|
let columns = [Column::hard("a", 10), Column::hard("b", 10)];
|
|
let props = DataTableProps {
|
|
title: Some("test".into()),
|
|
table_gap: 1,
|
|
left_to_right: false,
|
|
is_basic: false,
|
|
show_table_scroll_position: true,
|
|
show_current_entry_when_unfocused: false,
|
|
};
|
|
let styling = DataTableStyling::default();
|
|
|
|
DataTable::new(columns, props, styling)
|
|
}
|
|
|
|
#[test]
|
|
fn test_scrolling() {
|
|
let mut table = create_test_table();
|
|
table.set_data((0..=4).map(|index| TestType { index }).collect::<Vec<_>>());
|
|
|
|
table.scroll_to_last();
|
|
assert_eq!(table.current_index(), 4);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
|
|
|
|
table.scroll_to_first();
|
|
assert_eq!(table.current_index(), 0);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Up);
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_position() {
|
|
let mut table = create_test_table();
|
|
table.set_data((0..=4).map(|index| TestType { index }).collect::<Vec<_>>());
|
|
|
|
table.set_position(4);
|
|
assert_eq!(table.current_index(), 4);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
|
|
|
|
table.set_position(100);
|
|
assert_eq!(table.current_index(), 4);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
|
|
assert_eq!(table.current_item(), Some(&TestType { index: 4 }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_increment_position() {
|
|
let mut table = create_test_table();
|
|
table.set_data((0..=4).map(|index| TestType { index }).collect::<Vec<_>>());
|
|
|
|
table.set_position(4);
|
|
assert_eq!(table.current_index(), 4);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
|
|
|
|
table.increment_position(-1);
|
|
assert_eq!(table.current_index(), 3);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Up);
|
|
assert_eq!(table.current_item(), Some(&TestType { index: 3 }));
|
|
|
|
table.increment_position(-3);
|
|
assert_eq!(table.current_index(), 0);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Up);
|
|
assert_eq!(table.current_item(), Some(&TestType { index: 0 }));
|
|
|
|
table.increment_position(-3);
|
|
assert_eq!(table.current_index(), 0);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Up);
|
|
assert_eq!(table.current_item(), Some(&TestType { index: 0 }));
|
|
|
|
table.increment_position(1);
|
|
assert_eq!(table.current_index(), 1);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
|
|
assert_eq!(table.current_item(), Some(&TestType { index: 1 }));
|
|
|
|
table.increment_position(3);
|
|
assert_eq!(table.current_index(), 4);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
|
|
assert_eq!(table.current_item(), Some(&TestType { index: 4 }));
|
|
|
|
table.increment_position(10);
|
|
assert_eq!(table.current_index(), 4);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
|
|
assert_eq!(table.current_item(), Some(&TestType { index: 4 }));
|
|
|
|
// Make sure that overscrolling up causes clamping.
|
|
table.increment_position(-10);
|
|
assert_eq!(table.current_index(), 0);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Up);
|
|
assert_eq!(table.current_item(), Some(&TestType { index: 0 }));
|
|
|
|
// Make sure that overscrolling down causes clamping.
|
|
table.increment_position(100);
|
|
assert_eq!(table.current_index(), 4);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
|
|
assert_eq!(table.current_item(), Some(&TestType { index: 4 }));
|
|
}
|
|
|
|
/// A test to ensure that scroll offsets are correctly handled when we "lose" rows.
|
|
#[test]
|
|
fn test_lose_data() {
|
|
let mut table = create_test_table();
|
|
table.set_data((0..=4).map(|index| TestType { index }).collect::<Vec<_>>());
|
|
|
|
table.set_position(4);
|
|
assert_eq!(table.current_index(), 4);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
|
|
assert_eq!(table.current_item(), Some(&TestType { index: 4 }));
|
|
|
|
table.set_data((0..=2).map(|index| TestType { index }).collect::<Vec<_>>());
|
|
assert_eq!(table.current_index(), 2);
|
|
assert_eq!(table.state.scroll_direction, ScrollDirection::Down);
|
|
assert_eq!(table.current_item(), Some(&TestType { index: 2 }));
|
|
}
|
|
}
|