diff options
Diffstat (limited to 'src/canvas')
31 files changed, 4451 insertions, 132 deletions
diff --git a/src/canvas/components.rs b/src/canvas/components.rs new file mode 100644 index 00000000..05a63112 --- /dev/null +++ b/src/canvas/components.rs @@ -0,0 +1,8 @@ +//! Lower-level components used throughout bottom. + +pub mod data_table; +pub mod time_graph; +mod tui_widget; +pub mod widget_carousel; + +pub use tui_widget::*; diff --git a/src/canvas/components/data_table.rs b/src/canvas/components/data_table.rs new file mode 100644 index 00000000..f4e26f0c --- /dev/null +++ b/src/canvas/components/data_table.rs @@ -0,0 +1,249 @@ +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. +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 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 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`]. + pub fn increment_position(&mut self, change: i64) -> Option<usize> { + let max_index = self.data.len(); + let current_index = self.state.current_index; + + if change == 0 + || (change > 0 && current_index == max_index) + || (change < 0 && current_index == 0) + { + return None; + } + + let csp: Result<i64, _> = self.state.current_index.try_into(); + if let Ok(csp) = csp { + let proposed: Result<usize, _> = (csp + change).try_into(); + if let Ok(proposed) = proposed { + if proposed < self.data.len() { + self.state.current_index = proposed; + self.state.scroll_direction = if change < 0 { + ScrollDirection::Up + } else { + ScrollDirection::Down + }; + + return Some(self.state.current_index); + } + } + } + + None + } + + /// Updates the scroll position to a selected index. + #[allow(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![] + } + } + + #[test] + fn test_data_table_operations() { + 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(); + + let mut table = DataTable::new(columns, props, styling); + table.set_data((0..=4).map(|index| TestType { index }).collect::<Vec<_>>()); + + table.to_last(); + assert_eq!(table.current_index(), 4); + assert_eq!(table.state.scroll_direction, ScrollDirection::Down); + + table.to_first(); + assert_eq!(table.current_index(), 0); + assert_eq!(table.state.scroll_direction, ScrollDirection::Up); + + 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 })); + + 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 })); + + 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 })); + } +} diff --git a/src/canvas/components/data_table/column.rs b/src/canvas/components/data_table/column.rs new file mode 100644 index 00000000..7bbfcbfa --- /dev/null +++ b/src/canvas/components/data_table/column.rs @@ -0,0 +1,280 @@ +use std::{ + borrow::Cow, + cmp::{max, min}, + num::NonZeroU16, +}; + +/// A bound on the width of a column. +#[derive(Clone, Copy, Debug)] +pub enum ColumnWidthBounds { + /// A width of this type is as long as `desired`, but can otherwise shrink and grow up to a point. + Soft { + /// The desired, calculated width. Take this if possible as the base starting width. + desired: u16, + + /// The max width, as a percentage of the total width available. If [`None`], + /// then it can grow as desired. + max_percentage: Option<f32>, + }, + + /// A width of this type is either as long as specified, or does not appear at all. + Hard(u16), + + /// A width of this type always resizes to the column header's text width. + FollowHeader, +} + +pub trait ColumnHeader { + /// The "text" version of the column header. + fn text(&self) -> Cow<'static, str>; + + /// The version displayed when drawing the table. Defaults to [`ColumnHeader::text`]. + #[inline(always)] + fn header(&self) -> Cow<'static, str> { + self.text() + } +} + +impl ColumnHeader for &'static str { + fn text(&self) -> Cow<'static, str> { + Cow::Borrowed(self) + } +} + +impl ColumnHeader for String { + fn text(&self) -> Cow<'static, str> { + Cow::Owned(self.clone()) + } +} + +pub trait DataTableColumn<H: ColumnHeader> { + fn inner(&self) -> &H; + + fn inner_mut(&mut self) -> &mut H; + + fn bounds(&self) -> ColumnWidthBounds; + + fn bounds_mut(&mut self) -> &mut ColumnWidthBounds; + + fn is_hidden(&self) -> bool; + + fn set_is_hidden(&mut self, is_hidden: bool); + + /// The actually displayed "header". + fn header(&self) -> Cow<'static, str>; + + /// The header length, along with any required additional lengths for things like arrows. + /// Defaults to getting the length of [`DataTableColumn::header`]. + fn header_len(&self) -> usize { + self.header().len() + } +} + +#[derive(Clone, Debug)] +pub struct Column<H> { + /// The inner column header. + inner: H, + + /// A restriction on this column's width. + bounds: ColumnWidthBounds, + + /// Marks that this column is currently "hidden", and should *always* be skipped. + is_hidden: bool, +} + +impl<H: ColumnHeader> DataTableColumn<H> for Column<H> { + #[inline] + fn inner(&self) -> &H { + &self.inner + } + + #[inline] + fn inner_mut(&mut self) -> &mut H { + &mut self.inner + } + + #[inline] + fn bounds(&self) -> ColumnWidthBounds { + self.bounds + } + + #[inline] + fn bounds_mut(&mut self) -> &mut ColumnWidthBounds { + &mut self.bounds + } + + #[inline] + fn is_hidden(&self) -> bool { + self.is_hidden + } + + #[inline] + fn set_is_hidden(&mut self, is_hidden: bool) { + self.is_hidden = is_hidden; + } + + fn header(&self) -> Cow<'static, str> { + self.inner.text() + } +} + +impl<H: ColumnHeader> Column<H> { + pub const fn new(inner: H) -> Self { + Self { + inner, + bounds: ColumnWidthBounds::FollowHeader, + is_hidden: false, + } + } + + pub const fn hard(inner: H, width: u16) -> Self { + Self { + inner, + bounds: ColumnWidthBounds::Hard(width), + is_hidden: false, + } + } + + pub const fn soft(inner: H, max_percentage: Option<f32>) -> Self { + Self { + inner, + bounds: ColumnWidthBounds::Soft { + desired: 0, + max_percentage, + }, + is_hidden: false, + } + } +} + +pub trait CalculateColumnWidths<H> { + /// Calculates widths for the columns of this table, given the current width when called. + /// + /// * `total_width` is the total width on the canvas that the columns can try and work with. + /// * `left_to_right` is whether to size from left-to-right (`true`) or right-to-left (`false`). + fn calculate_column_widths(&self, total_width: u16, left_to_right: bool) -> Vec<NonZeroU16>; +} + +impl<H, C> CalculateColumnWidths<H> for [C] +where + H: ColumnHeader, + C: DataTableColumn<H>, +{ + fn calculate_column_widths(&self, total_width: u16, left_to_right: bool) -> Vec<NonZeroU16> { + use itertools::Either; + + const COLUMN_SPACING: u16 = 1; + + #[inline] + fn stop_allocating_space(desired: u16, available: u16) -> bool { + desired > available || desired == 0 + } + + let mut total_width_left = total_width; + let mut calculated_widths = vec![]; + let columns = if left_to_right { + Either::Left(self.iter()) + } else { + Either::Right(self.iter().rev()) + }; + + for column in columns { + if column.is_hidden() { + continue; + } + + match &column.bounds() { + ColumnWidthBounds::Soft { + desired, + max_percentage, + } => { + let min_width = column.header_len() as u16; + if min_width > total_width_left { + break; + } + + let soft_limit = max( + if let Some(max_percentage) = max_percentage { + ((*max_percentage * f32::from(total_width)).ceil()) as u16 + } else { + *desired + }, + min_width, + ); + let space_taken = min(min(soft_limit, *desired), total_width_left); + + if stop_allocating_space(space_taken, total_width_left) { + break; + } else { + total_width_left = + total_width_left.saturating_sub(space_taken + COLUMN_SPACING); + + // SAFETY: This is safe as we call `stop_allocating_space` which checks that + // the value pushed is greater than zero. + unsafe { + calculated_widths.push(NonZeroU16::new_unchecked(space_taken)); + } + } + } + ColumnWidthBounds::Hard(width) => { + let min_width = *width; + if stop_allocating_space(min_width, total_width_left) { + break; + } else { + total_width_left = + total_width_left.saturating_sub(min_width + COLUMN_SPACING); + + // SAFETY: This is safe as we call `stop_allocating_space` which checks that + // the value pushed is greater than zero. + unsafe { + calculated_widths.push(NonZeroU16::new_unchecked(min_width)); + } + } + } + ColumnWidthBounds::FollowHeader => { + let min_width = column.header_len() as u16; + if stop_allocating_space(min_width, total_width_left) { + break; + } else { + total_width_left = + total_width_left.saturating_sub(min_width + COLUMN_SPACING); + + // SAFETY: This is safe as we call `stop_allocating_space` which checks that + // the value pushed is greater than zero. + unsafe { + calculated_widths.push(NonZeroU16::new_unchecked(min_width)); + } + } + } + } + } + + if !calculated_widths.is_empty() { + if !left_to_right { + calculated_widths.reverse(); + } + + // Redistribute remaining space. + let mut num_dist = calculated_widths.len() as u16; + let amount_per_slot = total_width_left / num_dist; // Safe from DBZ by above empty check. + total_width_left %= num_dist; + + for width in calculated_widths.iter_mut() { + if num_dist == 0 { + break; + } + + if total_width_left > 0 { + *width = width.saturating_add(amount_per_slot + 1); + total_width_left -= 1; + } else { + *width = width.saturating_add(amount_per_slot); + } + + num_dist -= 1; + } + } + + calculated_widths + } +} diff --git a/src/canvas/components/data_table/data_type.rs b/src/canvas/components/data_table/data_type.rs new file mode 100644 index 00000000..ebac3c07 --- /dev/null +++ b/src/canvas/components/data_table/data_type.rs @@ -0,0 +1,28 @@ +use std::{borrow::Cow, num::NonZeroU16}; + +use tui::widgets::Row; + +use super::{ColumnHeader, DataTableColumn}; +use crate::canvas::Painter; + +pub trait DataToCell<H> +where + H: ColumnHeader, +{ + /// Given data, a column, and its corresponding width, return the string in the cell that will + /// be displayed in the [`DataTable`](super::DataTable). + fn to_cell(&self, column: &H, calculated_width: NonZeroU16) -> Option<Cow<'static, str>>; + + /// Apply styling to the generated [`Row`] of cells. + /// + /// The default implementation just returns the `row` that is passed in. + #[inline(always)] + fn style_row<'a>(&self, row: Row<'a>, _painter: &Painter) -> Row<'a> { + row + } + + /// Returns the desired column widths in light of having seen data. + fn column_widths<C: DataTableColumn<H>>(data: &[Self], columns: &[C]) -> Vec<u16> + where + Self: Sized; +} diff --git a/src/canvas/components/data_table/draw.rs b/src/canvas/components/data_table/draw.rs new file mode 100644 index 00000000..46304f83 --- /dev/null +++ b/src/canvas/components/data_table/draw.rs @@ -0,0 +1,281 @@ +use std::{ + cmp::{max, min}, + iter::once, +}; + +use concat_string::concat_string; +use tui::{ + layout::{Constraint, Direction, Layout, Rect}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Row, Table}, + Frame, +}; +use unicode_segmentation::UnicodeSegmentation; + +use super::{ + CalculateColumnWidths, ColumnHeader, ColumnWidthBounds, DataTable, DataTableColumn, DataToCell, + SortType, +}; +use crate::{ + app::layout_manager::BottomWidget, + canvas::Painter, + constants::{SIDE_BORDERS, TABLE_GAP_HEIGHT_LIMIT}, + utils::strings::truncate_to_text, +}; + +pub enum SelectionState { + NotSelected, + Selected, + Expanded, +} + +impl SelectionState { + pub fn new(is_expanded: bool, is_on_widget: bool) -> Self { + if is_expanded { + SelectionState::Expanded + } else if is_on_widget { + SelectionState::Selected + } else { + SelectionState::NotSelected + } + } +} + +/// A [`DrawInfo`] is information required on each draw call. +pub struct DrawInfo { + pub loc: Rect, + pub force_redraw: bool, + pub recalculate_column_widths: bool, + pub selection_state: SelectionState, +} + +impl DrawInfo { + pub fn is_on_widget(&self) -> bool { + matches!(self.selection_state, SelectionState::Selected) + || matches!(self.selection_state, SelectionState::Expanded) + } + + pub fn is_expanded(&self) -> bool { + matches!(self.selection_state, SelectionState::Expanded) + } +} + +impl<DataType, H, S, C> DataTable<DataType, H, S, C> +where + DataType: DataToCell<H>, + H: ColumnHeader, + S: SortType, + C: DataTableColumn<H>, +{ + fn block<'a>(&self, draw_info: &'a DrawInfo, data_len: usize) -> Block<'a> { + let border_style = match draw_info.selection_state { + SelectionState::NotSelected => self.styling.border_style, + SelectionState::Selected | SelectionState::Expanded => { + self.styling.highlighted_border_style + } + }; + + if !self.props.is_basic { + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style); + + if let Some(title) = self.generate_title(draw_info, data_len) { + block.title(title) + } else { + block + } + } else if draw_info.is_on_widget() { + // Implies it is basic mode but selected. + Block::default() + .borders(SIDE_BORDERS) + .border_style(border_style) + } else { + Block::default().borders(Borders::NONE) + } + } + + /// Generates a title, given the available space. + pub fn generate_title<'a>( + &self, draw_info: &'a DrawInfo, total_items: usize, + ) -> Option<Line<'a>> { + self.props.title.as_ref().map(|title| { + let current_index = self.state.current_index.saturating_add(1); + let draw_loc = draw_info.loc; + let title_style = self.styling.title_style; + let border_style = if draw_info.is_on_widget() { + self.styling.highlighted_border_style + } else { + self.styling.border_style + }; + + let title = if self.props.show_table_scroll_position { + let pos = current_index.to_string(); + let tot = total_items.to_string(); + let title_string = concat_string!(title, "(", pos, " of ", tot, ") "); + + if title_string.len() + 2 <= draw_loc.width.into() { + title_string + } else { + title.to_string() + } + } else { + title.to_string() + }; + + if draw_info.is_expanded() { + let title_base = concat_string!(title, "── Esc to go back "); + let lines = "─".repeat(usize::from(draw_loc.width).saturating_sub( + UnicodeSegmentation::graphemes(title_base.as_str(), true).count() + 2, + )); + let esc = concat_string!("─", lines, "─ Esc to go back "); + Line::from(vec![ + Span::styled(title, title_style), + Span::styled(esc, border_style), + ]) + } else { + Line::from(Span::styled(title, title_style)) + } + }) + } + + pub fn draw( + &mut self, f: &mut Frame<'_>, draw_info: &DrawInfo, widget: Option<&mut BottomWidget>, + painter: &Painter, + ) { + let draw_horizontal = !self.props.is_basic || draw_info.is_on_widget(); + let draw_loc = draw_info.loc; + let margined_draw_loc = Layout::default() + .constraints([Constraint::Percentage(100)]) + .horizontal_margin(u16::from(!draw_horizontal)) + .direction(Direction::Horizontal) + .split(draw_loc)[0]; + + let block = self.block(draw_info, self.data.len()); + + let (inner_width, inner_height) = { + let inner_rect = block.inner(margined_draw_loc); + self.state.inner_rect = inner_rect; + (inner_rect.width, inner_rect.height) + }; + + if inner_width == 0 || inner_height == 0 { + f.render_widget(block, margined_draw_loc); + } else { + // Calculate widths + if draw_info.recalculate_column_widths { + let col_widths = DataType::column_widths(&self.data, &self.columns); + + self.columns + .iter_mut() + .zip(&col_widths) + .for_each(|(column, &width)| { + let header_len = column.header_len() as u16; + if let ColumnWidthBounds::Soft { + desired, + max_percentage: _, + } = &mut column.bounds_mut() + { + *desired = max(header_len, width); + } + }); + + self.state.calculated_widths = self + .columns + .calculate_column_widths(inner_width, self.props.left_to_right); + + // Update draw loc in widget map + if let Some(widget) = widget { + widget.top_left_corner = Some((draw_loc.x, draw_loc.y)); + widget.bottom_right_corner = + Some((draw_loc.x + draw_loc.width, draw_loc.y + draw_loc.height)); + } + } + + let show_header = inner_height > 1; + let header_height = u16::from(show_header); + let table_gap = if !show_header || draw_loc.height < TABLE_GAP_HEIGHT_LIMIT { + 0 + } else { + self.props.table_gap + }; + + if !self.data.is_empty() || !self.first_draw { + if self.first_draw { + |