From 77777ef5887c2e1bd1a10c55cbb329c0471d4388 Mon Sep 17 00:00:00 2001 From: Clement Tsang <34804052+ClementTsang@users.noreply.github.com> Date: Tue, 2 Jan 2024 06:24:13 +0000 Subject: refactor: clean up some more drawing/component code (#1372) * rename battery info widget file * add widget trait * move basic table arrows over * some renaming * more renaming and shuffling * cleanup * fmt --- src/canvas.rs | 10 +- src/canvas/components.rs | 8 + src/canvas/components/data_table.rs | 247 ++++++++ src/canvas/components/data_table/column.rs | 256 +++++++++ src/canvas/components/data_table/data_type.rs | 25 + src/canvas/components/data_table/draw.rs | 289 ++++++++++ src/canvas/components/data_table/props.rs | 21 + src/canvas/components/data_table/sortable.rs | 544 ++++++++++++++++++ src/canvas/components/data_table/state.rs | 86 +++ src/canvas/components/data_table/styling.rs | 26 + src/canvas/components/time_graph.rs | 266 +++++++++ src/canvas/components/tui.rs | 4 + src/canvas/components/tui/pipe_gauge.rs | 224 ++++++++ src/canvas/components/tui/time_chart.rs | 711 ++++++++++++++++++++++++ src/canvas/components/tui/time_chart/canvas.rs | 550 ++++++++++++++++++ src/canvas/components/widget_carousel.rs | 158 ++++++ src/canvas/tui_widgets.rs | 6 - src/canvas/tui_widgets/data_table.rs | 247 -------- src/canvas/tui_widgets/data_table/column.rs | 256 --------- src/canvas/tui_widgets/data_table/data_type.rs | 25 - src/canvas/tui_widgets/data_table/draw.rs | 289 ---------- src/canvas/tui_widgets/data_table/props.rs | 21 - src/canvas/tui_widgets/data_table/sortable.rs | 544 ------------------ src/canvas/tui_widgets/data_table/state.rs | 86 --- src/canvas/tui_widgets/data_table/styling.rs | 26 - src/canvas/tui_widgets/pipe_gauge.rs | 224 -------- src/canvas/tui_widgets/time_chart.rs | 711 ------------------------ src/canvas/tui_widgets/time_chart/canvas.rs | 550 ------------------ src/canvas/tui_widgets/time_graph.rs | 266 --------- src/canvas/widgets.rs | 1 - src/canvas/widgets/basic_table_arrows.rs | 158 ------ src/canvas/widgets/cpu_basic.rs | 2 +- src/canvas/widgets/cpu_graph.rs | 4 +- src/canvas/widgets/disk_table.rs | 2 +- src/canvas/widgets/mem_basic.rs | 2 +- src/canvas/widgets/mem_graph.rs | 2 +- src/canvas/widgets/network_graph.rs | 4 +- src/canvas/widgets/process_table.rs | 2 +- src/canvas/widgets/temperature_table.rs | 2 +- src/data_conversion.rs | 2 +- src/widgets.rs | 11 +- src/widgets/battery_info.rs | 5 + src/widgets/battery_widget.rs | 5 - src/widgets/cpu_graph.rs | 4 +- src/widgets/disk_table.rs | 4 +- src/widgets/process_table.rs | 4 +- src/widgets/process_table/proc_widget_column.rs | 2 +- src/widgets/process_table/proc_widget_data.rs | 2 +- src/widgets/process_table/sort_table.rs | 2 +- src/widgets/temperature_table.rs | 4 +- 50 files changed, 3457 insertions(+), 3443 deletions(-) create mode 100644 src/canvas/components.rs create mode 100644 src/canvas/components/data_table.rs create mode 100644 src/canvas/components/data_table/column.rs create mode 100644 src/canvas/components/data_table/data_type.rs create mode 100644 src/canvas/components/data_table/draw.rs create mode 100644 src/canvas/components/data_table/props.rs create mode 100644 src/canvas/components/data_table/sortable.rs create mode 100644 src/canvas/components/data_table/state.rs create mode 100644 src/canvas/components/data_table/styling.rs create mode 100644 src/canvas/components/time_graph.rs create mode 100644 src/canvas/components/tui.rs create mode 100644 src/canvas/components/tui/pipe_gauge.rs create mode 100644 src/canvas/components/tui/time_chart.rs create mode 100644 src/canvas/components/tui/time_chart/canvas.rs create mode 100644 src/canvas/components/widget_carousel.rs delete mode 100644 src/canvas/tui_widgets.rs delete mode 100644 src/canvas/tui_widgets/data_table.rs delete mode 100644 src/canvas/tui_widgets/data_table/column.rs delete mode 100644 src/canvas/tui_widgets/data_table/data_type.rs delete mode 100644 src/canvas/tui_widgets/data_table/draw.rs delete mode 100644 src/canvas/tui_widgets/data_table/props.rs delete mode 100644 src/canvas/tui_widgets/data_table/sortable.rs delete mode 100644 src/canvas/tui_widgets/data_table/state.rs delete mode 100644 src/canvas/tui_widgets/data_table/styling.rs delete mode 100644 src/canvas/tui_widgets/pipe_gauge.rs delete mode 100644 src/canvas/tui_widgets/time_chart.rs delete mode 100644 src/canvas/tui_widgets/time_chart/canvas.rs delete mode 100644 src/canvas/tui_widgets/time_graph.rs delete mode 100644 src/canvas/widgets/basic_table_arrows.rs create mode 100644 src/widgets/battery_info.rs delete mode 100644 src/widgets/battery_widget.rs diff --git a/src/canvas.rs b/src/canvas.rs index 6b18f1c7..38194f04 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -1,7 +1,7 @@ +pub mod components; mod dialogs; mod drawing_utils; pub mod styling; -pub mod tui_widgets; mod widgets; use std::str::FromStr; @@ -49,7 +49,7 @@ impl FromStr for ColourScheme { "nord" => Ok(ColourScheme::Nord), "nord-light" => Ok(ColourScheme::NordLight), _ => Err(BottomError::ConfigError(format!( - "\"{s}\" is an invalid built-in color scheme." + "`{s}` is an invalid built-in color scheme." ))), } } @@ -71,8 +71,10 @@ pub struct Painter { widget_layout: BottomLayout, } -// Part of a temporary fix for https://github.com/ClementTsang/bottom/issues/896 -enum LayoutConstraint { +/// The constraints of a widget relative to its parent. +/// +/// This is used over ratatui's internal representation due to https://github.com/ClementTsang/bottom/issues/896. +pub enum LayoutConstraint { CanvasHandled, Grow, Ratio(u32, u32), diff --git a/src/canvas/components.rs b/src/canvas/components.rs new file mode 100644 index 00000000..13a2d371 --- /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; +pub mod widget_carousel; + +pub use tui::*; diff --git a/src/canvas/components/data_table.rs b/src/canvas/components/data_table.rs new file mode 100644 index 00000000..5fb4a591 --- /dev/null +++ b/src/canvas/components/data_table.rs @@ -0,0 +1,247 @@ +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> { + pub columns: Vec, + pub state: DataTableState, + pub props: DataTableProps, + pub styling: DataTableStyling, + data: Vec, + sort_type: S, + first_draw: bool, + first_index: Option, + _pd: PhantomData<(DataType, S, Header)>, +} + +impl, H: ColumnHeader> DataTable> { + pub fn new>>>( + 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, H: ColumnHeader, S: SortType, C: DataTableColumn> + DataTable +{ + /// 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) { + 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 { + 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 = self.state.current_index.try_into(); + if let Ok(csp) = csp { + let proposed: Result = (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 tui-rs' internal selection. + pub fn tui_selected(&self) -> Option { + self.state.table_state.selected() + } +} + +#[cfg(test)] +mod test { + 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: u16, + ) -> Option> { + None + } + + fn column_widths>( + _data: &[Self], _columns: &[C], + ) -> Vec + 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::>()); + + 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::>()); + 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..e7b82b29 --- /dev/null +++ b/src/canvas/components/data_table/column.rs @@ -0,0 +1,256 @@ +use std::{ + borrow::Cow, + cmp::{max, min}, +}; + +/// A bound on the width of a column. +#[derive(Clone, Copy, Debug)] +pub enum ColumnWidthBounds { + /// A width of this type is either as long as `min`, 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, + }, + + /// 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 { + 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 { + /// 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 DataTableColumn for Column { + #[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 Column { + 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) -> Self { + Self { + inner, + bounds: ColumnWidthBounds::Soft { + desired: 0, + max_percentage, + }, + is_hidden: false, + } + } +} + +pub trait CalculateColumnWidths { + /// 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; +} + +impl CalculateColumnWidths for [C] +where + H: ColumnHeader, + C: DataTableColumn, +{ + fn calculate_column_widths(&self, total_width: u16, left_to_right: bool) -> Vec { + use itertools::Either; + + let mut total_width_left = total_width; + let mut calculated_widths = vec![0; self.len()]; + let columns = if left_to_right { + Either::Left(self.iter().zip(calculated_widths.iter_mut())) + } else { + Either::Right(self.iter().zip(calculated_widths.iter_mut()).rev()) + }; + + let mut num_columns = 0; + for (column, calculated_width) 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 min_width > space_taken || min_width == 0 { + break; + } else if space_taken > 0 { + total_width_left = total_width_left.saturating_sub(space_taken + 1); + *calculated_width = space_taken; + num_columns += 1; + } + } + ColumnWidthBounds::Hard(width) => { + let min_width = *width; + if min_width > total_width_left || min_width == 0 { + break; + } else if min_width > 0 { + total_width_left = total_width_left.saturating_sub(min_width + 1); + *calculated_width = min_width; + num_columns += 1; + } + } + ColumnWidthBounds::FollowHeader => { + let min_width = column.header_len() as u16; + if min_width > total_width_left || min_width == 0 { + break; + } else if min_width > 0 { + total_width_left = total_width_left.saturating_sub(min_width + 1); + *calculated_width = min_width; + num_columns += 1; + } + } + } + } + + if num_columns > 0 { + // Redistribute remaining. + let mut num_dist = num_columns; + let amount_per_slot = total_width_left / num_dist; + total_width_left %= num_dist; + + for width in calculated_widths.iter_mut() { + if num_dist == 0 { + break; + } + + if *width > 0 { + if total_width_left > 0 { + *width += amount_per_slot + 1; + total_width_left -= 1; + } else { + *width += 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..bbfceb8c --- /dev/null +++ b/src/canvas/components/data_table/data_type.rs @@ -0,0 +1,25 @@ +use tui::{text::Text, widgets::Row}; + +use super::{ColumnHeader, DataTableColumn}; +use crate::canvas::Painter; + +pub trait DataToCell +where + H: ColumnHeader, +{ + /// Given data, a column, and its corresponding width, return what should be displayed in the [`DataTable`](super::DataTable). + fn to_cell(&self, column: &H, calculated_width: u16) -> Option>; + + /// 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>(data: &[Self], columns: &[C]) -> Vec + 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..15faae22 --- /dev/null +++ b/src/canvas/components/data_table/draw.rs @@ -0,0 +1,289 @@ +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}, +}; + +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 DataTable +where + DataType: DataToCell, + H: ColumnHeader, + S: SortType, + C: DataTableColumn, +{ + 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> { + 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 { + self.first_draw = false; // TODO: Doing it this way is fine, but it could be done better (e.g. showing custom no results/entries message) + if let Some(first_index) = self.first_index { + self.set_position(first_index); + } + } + + let columns = &self.columns; + let rows = { + let num_rows = + usize::from(inner_height.saturating_sub(table_gap + header_height)); + self.state + .get_start_position(num_rows, draw_info.force_redraw); + let start = self.state.display_start_index; + let end = min(self.data.len(), start + num_rows); + self.state + .table_state + .select(Some(self.state.current_index.saturating_sub(start))); + + self.data[start..end].iter().map(|data_row| { + let row = Row::new( + columns + .iter() + .zip(&self.state.calculated_widths) + .filter_map(|(column, &width)| { + data_row.to_cell(column.inner(), width) + }), + ); + + data_row.style_row(row, painter) + }) + }; + + let headers = self + .sort_type + .build_header(columns, &self.state.calculated_widths) + .style(self.styling.header_style) + .bottom_margin(table_gap); + + let widget = { + let highlight_style = if draw_info.is_on_widget() + || self.props.show_current_entry_when_unfocused + { + self.styling.highlighted_text_style + } else { + self.styling.text_style + }; + let mut table = Table::new( + rows, + &(self + .state + .calculated_widths + .iter() + .filter_map(|&width| { + if width == 0 { + None + } else { + Some(Constraint::Length(width)) + } + }) + .collect::>()), + ) + .block(block) + .highlight_style(highlight_style) + .style(self.styling.text_style); + + if show_header { + table = table.header(headers); + } + + table + }; + + let table_state = &mut self.state.table_state; + f.render_stateful_widget(widget, margined_draw_loc, table_state); + } else { + let table = Table::new( + once(Row::new(Text::raw("No data"))), + [Constraint::Percentage(100)], + ) + .block(block) + .style(self.styling.text_style); + f.render_widget(table, margined_draw_loc); + } + } + } +} diff --git a/src/canvas/components/data_table/props.rs b/src/canvas/components/data_table/props.rs new file mode 100644 index 00000000..66d3a4f0 --- /dev/null +++ b/src/canvas/components/data_table/props.rs @@ -0,0 +1,21 @@ +use std::borrow::Cow; + +pub struct DataTableProps { + /// An optional title for the table. + pub title: Option>, + + /// The size of the gap between the header and rows. + pub table_gap: u16, + + /// Whether this table determines column widths from left to right. + pub left_to_right: bool, + + /// Whether this table is a basic table. This affects the borders. + pub is_basic: bool, + + /// Whether to show the table scroll position. + pub show_table_scroll_position: bool, + + /// Whether to show the current entry as highlighted when not focused. + pub show_current_entry_when_unfocused: bool, +} diff --git a/src/canvas/components/data_table/sortable.rs b/src/canvas/components/data_table/sortable.rs new file mode 100644 index 00000000..f6c3b502 --- /dev/null +++ b/src/canvas/components/data_table/sortable.rs @@ -0,0 +1,544 @@ +use std::{borrow::Cow, marker::PhantomData}; + +use concat_string::concat_string; +use itertools::Itertools; +use tui::widgets::Row; + +use super::{ + ColumnHeader, ColumnWidthBounds, DataTable, DataTableColumn, DataTableProps, DataTableState, + DataTableStyling, DataToCell, +}; +use crate::utils::general::truncate_to_text; + +/// Denotes the sort order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SortOrder { + Ascending, + Descending, +} + +impl SortOrder { + /// Returns the reverse [`SortOrder`]. + pub fn rev(&self) -> SortOrder { + match self { + SortOrder::Ascending => SortOrder::Descending, + SortOrder::Descending => SortOrder::Ascending, + } + } +} + +impl Default for SortOrder { + fn default() -> Self { + Self::Ascending + } +} + +/// Denotes the [`DataTable`] is unsorted. +pub struct Unsortable; + +/// Denotes the [`DataTable`] is sorted. +pub struct Sortable { + /// The currently selected sort index. + pub sort_index: usize, + + /// The current sorting order. + pub order: SortOrder, +} + +/// The [`SortType`] trait is meant to be used in the typing of a [`DataTable`] +/// to denote whether the table is meant to display/store sorted or unsorted data. +/// +/// Note that the trait is [sealed](https://rust-lang.github.io/api-guidelines/future-proofing.html#sealed-traits-protect-against-downstream-implementations-c-sealed), +/// and therefore only [`Unsortable`] and [`Sortable`] can implement it. +pub trait SortType: private::Sealed { + /// Constructs the table header. + fn build_header(&self, columns: &[C], widths: &[u16]) -> Row<'_> + where + H: ColumnHeader, + C: DataTableColumn, + { + Row::new(columns.iter().zip(widths).filter_map(|(c, &width)| { + if width == 0 { + None + } else { + Some(truncate_to_text(&c.header(), width)) + } + })) + } +} + +mod private { + use super::{Sortable, Unsortable}; + + pub trait Sealed {} + + impl Sealed for Unsortable {} + impl Sealed for Sortable {} +} + +impl SortType for Unsortable {} + +impl SortType for Sortable { + fn build_header(&self, columns: &[C], widths: &[u16]) -> Row<'_> + where + H: ColumnHeader, + C: DataTableColumn, + { + const UP_ARROW: &str = "▲"; + const DOWN_ARROW: &str = "▼"; + + Row::new( + columns + .iter() + .zip(widths) + .enumerate() + .filter_map(|(index, (c, &width))| { + if width == 0 { + None + } else if index == self.sort_index { + let arrow = match self.order { + SortOrder::Ascending => UP_ARROW, + SortOrder::Descending => DOWN_ARROW, + }; + Some(truncate_to_text(&concat_string!(c.header(), arrow), width)) + } else { + Some(truncate_to_text(&c.header(), width)) + } + }), + ) + } +} + +pub trait SortsRow { + type DataType; + + /// Sorts data. + fn sort_data(&self, data: &mut [Self::DataType], descending: bool); +} + +#[derive(Debug, Clone)] +pub struct SortColumn { + /// The inner column header. + inner: T, + + /// The default sort order. + pub default_order: SortOrder, + + /// A restriction on this column's width. + pub bounds: ColumnWidthBounds, + + /// Marks that this column is currently "hidden", and should *always* be skipped. + pub is_hidden: bool, +} + +impl DataTableColumn for SortColumn +where + T: ColumnHeader + SortsRow, +{ + #[inline] + fn inner(&self) -> &T { + &self.inner + } + + #[inline] + fn inner_mut(&mut self) -> &mut T { + &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.header() + } + + fn header_len(&self) -> usize { + self.header().len() + 1 + } +} + +impl SortColumn +where + T: ColumnHeader + SortsRow, +{ + /// Creates a new [`SortColumn`] with a width that follows the header width, which has no shortcut and sorts by + /// default in ascending order ([`SortOrder::Ascending`]). + pub fn new(inner: T) -> Self { + Self { + inner, + bounds: ColumnWidthBounds::FollowHeader, + is_hidden: false, + default_order: SortOrder::default(), + } + } + + /// Creates a new [`SortColumn`] with a hard width, which has no shortcut and sorts by default in + /// ascending order ([`SortOrder::Ascending`]). + pub fn hard(inner: T, width: u16) -> Self { + Self { + inner, + bounds: ColumnWidthBounds::Hard(width), + is_hidden: false, + default_order: SortOrder::default(), + } + } + + /// Creates a new [`SortColumn`] with a soft width, which has no shortcut and sorts by default in + /// ascending order ([`SortOrder::Ascending`]). + pub fn soft(inner: T, max_percentage: Option) -> Self { + Self { + inner, + bounds: ColumnWidthBounds::Soft { + desired: 0, + max_percentage, + }, + is_hidden: false, + default_order: SortOrder::default(), + } + } + + /// Sets the default sort order to [`SortOrder::Ascending`]. + pub fn default_ascending(mut self) -> Self { + self.default_order = SortOrder::Ascending; + self + } + + /// Sets the default sort order to [`SortOrder::Descending`]. + pub fn default_descending(mut self) -> Self { + self.default_order = SortOrder::Descending; + self + } + + /// Given a [`SortColumn`] and the sort order, sort a mutable slice of associated data. + pub fn sort_by(&self, data: &mut [D], order: SortOrder) { + let descending = matches!(order, SortOrder::Descending); + self.inner.sort_data(data, descending); + } +} + +pub struct SortDataTableProps { + pub inner: DataTableProps, + pub sort_index: usize, + pub order: SortOrder, +} + +/// A type alias for a sortable [`DataTable`]. +pub type SortDataTable = DataTable>; + +impl SortDataTable +where + D: DataToCell, + H: ColumnHeader + SortsRow, +{ + pub fn new_sortable>>>( + columns: C, props: SortDataTableProps, styling: DataTableStyling, + ) -> Self { + Self { + columns: columns.into(), + state: DataTableState::default(), + props: props.inner, + styling, + sort_type: Sortable { + sort_index: props.sort_index, + order: props.order, + }, + first_draw: true, + first_index: None, + data: vec![], + _pd: PhantomData, + } + } + + /// Sets the current sort order. + pub fn set_order(&mut self, order: SortOrder) { + self.sort_type.order = order; + } + + /// Gets the current sort order. + pub fn order(&self) -> SortOrder { + self.sort_type.order + } + + /// Toggles the current sort order. + pub fn toggle_order(&mut self) { + self.sort_type.order = match self.sort_type.order { + SortOrder::Ascending => SortOrder::Descending, + SortOrder::Descending => SortOrder::Ascending, + } + } + + /// Given some `x` and `y`, if possible, select the corresponding column or toggle the column if already selected, + /// and otherwise do nothing. + /// + /// If there was some update, the corresponding column type will be returned. If nothing happens, [`None`] is + /// returned. + pub fn try_select_location(&mut self, x: u16, y: u16) -> Option { + if self.state.inner_rect.height > 1 && self.state.inner_rect.y == y { + if let Some(index) = self.get_range(x) { + self.set_sort_index(index); + Some(self.sort_type.sort_index) + } else { + None + } + } else { + None + } + } + + /// Updates the sort index, and sets the sort order as appropriate. + /// + /// If the index is different from the previous one, it will move to the new index and set the sort order + /// to the prescribed default sort order. + /// + /// If the index is the same as the previous one, it will simply toggle the current sort order. + pub fn set_sort_index(&mut self, index: usize) { + if self.sort_type.sort_index == index { + self.toggle_order(); + } else if let Some(col) = self.columns.get(index) { + self.sort_type.sort_index = index; + self.sort_type.order = col.default_order; + } + } + + /// Returns the current sort index. + pub fn sort_index(&self) -> usize { + self.sort_type.sort_index + } + + /// Given a `needle` coordinate, select the corresponding index and value. + fn get_range(&self, needle: u16) -> Option { + let mut start = self.state.inner_rect.x; + let range = self + .state + .calculated_widths + .iter() + .map(|width| { + let entry_start = start; + start += width + 1; // +1 for the gap b/w cols. + + entry_start + }) + .collect_vec(); + + match range.binary_search(&needle) { + Ok(index) => Some(index), + Err(index) => index.checked_sub(1), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[derive(Clone, PartialEq, Eq, Debug)] + struct TestType { + index: usize, + data: u64, + } + + enum ColumnType { + Index, + Data, + } + + impl DataToCell for TestType { + fn to_cell( + &self, _column: &ColumnType, _calculated_width: u16, + ) -> Option> { + None + } + + fn column_widths>(_data: &[Self], _columns: &[C]) -> Vec + where + Self: Sized, + { + vec![] + } + } + + impl ColumnHeader for ColumnType { + fn text(&self) -> Cow<'static, str> { + match self { + ColumnType::Index => "Index".into(), + ColumnType::Data => "Data".into(), + } + } + } + + impl SortsRow for ColumnType { + type DataType = TestType; + + fn sort_data(&self, data: &mut [TestType], descending: bool) { + match self { + ColumnType::Index => data.sort_by_key(|t| t.index), + ColumnType::Data => data.sort_by_key(|t| t.data), + } + + if descending { + data.reverse(); + } + } + } + + #[test] + fn test_sorting() { + let columns = [ + SortColumn::new(ColumnType::Index), + SortColumn::new(ColumnType::Data), + ]; + let props = { + let inner = 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, + }; + + SortDataTableProps { + inner, + sort_index: 0, + order: SortOrder::Descending, + } + }; + + let styling = DataTableStyling::default(); + + let mut table = DataTable::new_sortable(columns, props, styling); + let mut data = vec![ + TestType { + index: 4, + data: 100, + }, + TestType { + index: 1, + data: 200, + }, + TestType { + index: 0, + data: 300, + }, + TestType { + index: 3, + data: 400, + }, + TestType { + index: 2, + data: 500, + }, + ]; + + table + .columns + .get(table.sort_type.sort_index) + .unwrap() + .sort_by(&mut data, SortOrder::Ascending); + assert_eq!( + data, + vec![ + TestType { + index: 0, + data: 300, + }, + TestType { + index: 1, + data: 200, + }, + TestType { + index: 2, + data: 500, + }, + TestType { + index: 3, + data: 400, + }, + TestType { + index: 4, + data: 100, + }, + ] + ); + + table + .columns + .get(table.sort_type.sort_index) + .unwrap() + .sort_by(&mut data, SortOrder::Descending); + assert_eq!( + data, + vec![ + TestType { + index: 4, + data: 100, + }, + TestType { + index: 3, + data: 400, + }, + TestType { + index: 2, + data: 500, + }, + TestType { + index: 1, + data: 200, + }, + TestType { + index: 0, + data: 300, + }, + ] + ); + + table.set_sort_index(1); + table + .columns + .get(table.sort_type.sort_index) + .unwrap() + .sort_by(&mut data, SortOrder::Ascending); + assert_eq!( + data, + vec![ + TestType { + index: 4, + data: 100, + }, + TestType { + index: 1, + data: 200, + }, + TestType { + index: 0, + data: 300, + }, + TestType { + index: 3, + data: 400, + }, + TestType { + index: 2, + data: 500, + }, + ] + ); + } +} diff --git a/src/canvas/components/data_table/state.rs b/src/canvas/components/data_table/state.rs new file mode 100644 index 00000000..0e2ed450 --- /dev/null +++ b/src/canvas/components/data_table/state.rs @@ -0,0 +1,86 @@ +use tui::{layout::Rect, widgets::TableState}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] +pub enum ScrollDirection { + // UP means scrolling up --- this usually DECREMENTS + Up, + + // DOWN means scrolling down --- this usually INCREMENTS + #[default] + Down, +} + +/// Internal state representation of a [`DataTable`](super::DataTable). +pub struct DataTableState { + /// The index from where to start displaying the rows. + pub display_start_index: usize, + + /// The current scroll position. + pub current_index: usize, + + /// The direction of the last attempted scroll. + pub scroll_direction: ScrollDirection, + + /// tui-rs' internal table state. + pub table_state: TableState, + + /// The calculated widths. + pub calculated_widths: Vec, + + /// The current inner [`Rect`]. + pub inner_rect: Rect, +} + +impl Default for DataTableState { + fn default() -> Self { + Self { + display_start_index: 0, + current_index: 0, + scroll_direction: ScrollDirection::Down, + calculated_widths: vec![], + table_state: TableState::default(), + inner_rect: Rect::default(), + } + } +} + +impl DataTableState { + /// Gets the starting position of a table. + pub fn get_start_position(&mut self, num_rows: usize, is_force_redraw: bool) { + let start_index = if is_force_redraw { + 0 + } else { + self.display_start_index + }; + let current_scroll_position = self.current_index; + let scroll_direction = self.scroll_direction; + + self.display_start_index = match scroll_direction { + ScrollDirection::Down => { + if current_scroll_position < start_index + num_rows { + // If, using the current scroll position, we can see the element + // (so within that and + num_rows) just reuse the current previously + // scrolled position. + start_index + } else if current_scroll_position >= num_rows { + // If the current position past the last element visible in the list, + // then skip until we can see that element. + current_scroll_position - num_rows + 1 + } else { + // Else, if it is not past the last element visible, do not omit anything. + 0 + } + } + ScrollDirection::Up => { + if current_scroll_position <= start_index { + // If it's past the first element, then show from that element downwards + current_scroll_position + } else if current_scroll_position >= start_index + num_rows { + current_scroll_position - num_rows + 1 + } else { + start_index + } + } + }; + } +} diff --git a/src/canvas/components/data_table/styling.rs b/src/canvas/components/data_table/styling.rs new file mode 100644 index 00000000..80ce2b70 --- /dev/null +++ b/src/canvas/components/data_table/styling.rs @@ -0,0 +1,26 @@ +use tui::style::Style; + +use crate::canvas::styling::CanvasStyling; + +#[derive(Default)] +pub struct DataTableStyling { + pub header_style: Style, + pub border_style: Style, + pub highlighted_border_style: Style, + pub text_style: Style, + pub highlighted_text_style: Style, + pub title_style: Style, +} + +impl DataTableStyling { + pub fn from_colours(colours: &CanvasStyling) -> Self { + Self { + header_style: colours.table_header_style, + border_style: colours.border_style, + highlighted_border_style: colours.highlighted_border_style, + text_style: colours.text_style, + highlighted_text_style: colours.currently_selected_text_style, + title_style: colours.widget_title_style, + } + } +} diff --git a/src/canvas/components/time_graph.rs b/src/canvas/components/time_graph.rs new file mode 100644 index 00000000..bd19be74 --- /dev/null +++ b/src/canvas/components/time_graph.rs @@ -0,0 +1,266 @@ +use std::borrow::Cow; + +use concat_string::concat_string; +use tui::{ + layout::{Constraint, Rect}, + style::Style, + symbols::Marker, + text::{Line, Span}, + widgets::{Block, Borders, GraphType}, + Frame, +}; +use unicode_segmentation::UnicodeSegmentation; + +use super::time_chart::{Axis, Dataset, Point, TimeChart, DEFAULT_LEGEND_CONSTRAINTS}; + +/// Represents the data required by the [`TimeGraph`]. +pub struct GraphData<'a> { + pub points: &'a [Point], + pub style: Style, + pub name: Option>, +} + +pub struct TimeGraph<'a> { + /// The min and max x boundaries. Expects a f64 representing the time range in milliseconds. + pub x_bounds: [u64; 2], + + /// Whether to hide the time/x-labels. + pub hide_x_labels: bool, + + /// The min and max y boundaries. + pub y_bounds: [f64; 2], + + /// Any y-labels. + pub y_labels: &'a [Cow<'a, str>], + + /// The graph style. + pub graph_style: Style, + + /// The border style. + pub border_style: Style, + + /// The graph title. + pub title: Cow<'a, str>, + + /// Whether this graph is expanded. + pub is_expanded: bool, + + /// The title style. + pub title_style: Style, + + /// Any legend constraints. + pub legend_constraints: Option<(Constraint, Constraint)>, + + /// The marker type. Unlike tui-rs' native charts, we assume + /// only a single type of market. + pub marker: Marker, +} + +impl<'a> TimeGraph<'a> { + /// Generates the [`Axis`] for the x-axis. + fn generate_x_axis(&self) -> Axis<'_> { + // Due to how we display things, we need to adjust the time bound values. + let time_start = -(self.x_bounds[1] as f64); + let adjusted_x_bounds = [time_start, 0.0]; + + if self.hide_x_labels { + Axis::default().bounds(adjusted_x_bounds) + } else { + let xb_one = (self.x_bounds[1] / 1000).to_string(); + let xb_zero = (self.x_bounds[0] / 1000).to_string(); + + let x_labels = vec![ + Span::styled(concat_string!(xb_one, "s"), self.graph_style), + Span::styled(concat_string!(xb_zero, "s"), self.graph_style), + ]; + + Axis::default() + .bounds(adjusted_x_bounds) + .labels(x_labels) + .style(self.graph_style) + } + } + + /// Generates the [`Axis`] for the y-axis. + fn generate_y_axis(&self) -> Axis<'_> { + Axis::default() + .bounds(self.y_bounds) + .style(self.graph_style) + .labels( + self.y_labels + .iter() + .map(|label| Span::styled(label.clone(), self.graph_style)) + .collect(), + ) + } + + /// Generates a title for the [`TimeGraph`] widget, given the available space. + fn generate_title(&self, draw_loc: Rect) -> Line<'_> { + if self.is_expanded { + let title_base = concat_string!(self.title, "── Esc to go back "); + Line::from(vec![ + Span::styled(self.title.as_ref(), self.title_style), + Span::styled( + concat_string!( + "─", + "─".repeat(usize::from(draw_loc.width).saturating_sub( + UnicodeSegmentation::graphemes(title_base.as_str(), true).count() + 2 + )), + "─ Esc to go back " + ), + self.border_style, + ), + ]) + } else { + Line::from(Span::styled(self.title.as_ref(), self.title_style)) + } + } + + /// Draws a time graph at [`Rect`] location provided by `draw_loc`. A time graph is used to display data points + /// throughout time in the x-axis. + /// + /// This time graph: + /// - Draws with the higher time value on the left, and lower on the right. + /// - Expects a [`TimeGraph`] to be passed in, which details how to draw the graph. + /// - Expects `graph_data`, which represents *what* data to draw, and various details like style and optional legends. + pub fn draw_time_graph(&self, f: &mut Frame<'_>, draw_loc: Rect, graph_data: &[GraphData<'_>]) { + let x_axis = self.generate_x_axis(); + let y_axis = self.generate_y_axis(); + + // This is some ugly manual loop unswitching. Maybe unnecessary. + // TODO: Optimize this step. Cut out unneeded points. + let data = graph_data.iter().map(create_dataset).collect(); + let block = Block::default() + .title(self.generate_title(draw_loc)) + .borders(Borders::ALL) + .border_style(self.border_style); + + f.render_widget( + TimeChart::new(data) + .block(block) + .x_axis(x_axis) + .y_axis(y_axis) + .marker(self.marker) + .legend_style(self.graph_style) + .hidden_legend_constraints( + self.legend_constraints + .unwrap_or(DEFAULT_LEGEND_CONSTRAINTS), + ), + draw_loc, + ) + } +} + +/// Creates a new [`Dataset`]. +fn create_dataset<'a>(data: &'a GraphData<'a>) -> Dataset<'a> { + let GraphData { + points, + style, + name, + } = data; + + let dataset = Dataset::default() + .style(*style) + .data(points) + .graph_type(GraphType::Line); + + if let Some(name) = name { + dataset.name(name.as_ref()) + } else { + dataset + } +} + +#[cfg(test)] +mod test { + use std::borrow::Cow; + + use tui::{ + layout::Rect, + style::{Color, Style}, + symbols::Marker, + text::{Line, Span}, + }; + + use super::TimeGraph; + use crate::canvas::components::time_chart::Axis; + + const Y_LABELS: [Cow<'static, str>; 3] = [ + Cow::Borrowed("0%"), + Cow::Borrowed("50%"), + Cow::Borrowed("100%"), + ]; + + fn create_time_graph() -> TimeGraph<'static> { + TimeGraph { + title: " Network ".into(), + x_bounds: [0, 15000], + hide_x_labels: false, + y_bounds: [0.0, 100.5], + y_labels: &Y_LABELS, + graph_style: Style::default().fg(Color::Red), + border_style: Style::default().fg(Color::Blue), + is_expanded: false, + title_style: Style::default().fg(Color::Cyan), + legend_constraints: None, + marker: Marker::Braille, + } + } + + #[test] + fn time_graph_gen_x_axis() { + let tg = create_time_graph(); + let style = Style::default().fg(Color::Red); + let x_axis = tg.generate_x_axis(); + + let actual = Axis::default() + .bounds([-15000.0, 0.0]) + .labels(vec![Span::styled("15s", style), Span::styled("0s", style)]) + .style(style); + assert_eq!(x_axis.bounds, actual.bounds); + assert_eq!(x_axis.labels, actual.labels); + assert_eq!(x_axis.style, actual.style); + } + + #[test] + fn time_graph_gen_y_axis() { + let tg = create_time_graph(); + let style = Style::default().fg(Color::Red); + let y_axis = tg.generate_y_axis(); + + let actual = Axis::default() + .bounds([0.0, 100.5]) + .labels(vec![ + Span::styled("0%", style), + Span::styled("50%", style), + Span::styled("100%", style), + ]) + .style(style); + + assert_eq!(y_axis.bounds, actual.bounds); + assert_eq!(y_axis.labels, actual.labels); + assert_eq!(y_axis.style, actual.style); + } + + #[test] + fn time_graph_gen_title() { + let mut time_graph = create_time_graph(); + let draw_loc = Rect::new(0, 0, 32, 100); + + let title = time_graph.generate_title(draw_loc); + assert_eq!( + title, + Line::from(Span::styled(" Network ", Style::default().fg(Color::Cyan))) + ); + + time_graph.is_expanded = true; + let title = time_graph.generate_title(draw_loc); + assert_eq!( + title, +