diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/canvas.rs | 16 | ||||
-rw-r--r-- | src/canvas/components.rs | 10 | ||||
-rw-r--r-- | src/canvas/components/text_table.rs | 1 | ||||
-rw-r--r-- | src/canvas/components/time_chart.rs | 701 | ||||
-rw-r--r-- | src/canvas/components/time_graph.rs | 273 | ||||
-rw-r--r-- | src/canvas/drawing_utils.rs | 222 | ||||
-rw-r--r-- | src/canvas/widgets/cpu_graph.rs | 309 | ||||
-rw-r--r-- | src/canvas/widgets/mem_graph.rs | 258 | ||||
-rw-r--r-- | src/canvas/widgets/network_graph.rs | 972 |
9 files changed, 1718 insertions, 1044 deletions
diff --git a/src/canvas.rs b/src/canvas.rs index 9d8cd182..2070327a 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -25,14 +25,14 @@ use crate::{ Pid, }; +pub use self::components::Point; + mod canvas_colours; +mod components; mod dialogs; mod drawing_utils; mod widgets; -/// Point is of time, data -type Point = (f64, f64); - #[derive(Default)] pub struct DisplayableData { pub rx_display: String, @@ -201,6 +201,16 @@ impl Painter { Ok(painter) } + /// Determines the border style. + pub fn get_border_style(&self, widget_id: u64, selected_widget_id: u64) -> tui::style::Style { + let is_on_widget = widget_id == selected_widget_id; + if is_on_widget { + self.colours.highlighted_border_style + } else { + self.colours.border_style + } + } + fn generate_config_colours(&mut self, config: &Config) -> anyhow::Result<()> { if let Some(colours) = &config.colors { self.colours.set_colours_from_palette(colours)?; diff --git a/src/canvas/components.rs b/src/canvas/components.rs new file mode 100644 index 00000000..219c6245 --- /dev/null +++ b/src/canvas/components.rs @@ -0,0 +1,10 @@ +//! Some common components to reuse when drawing widgets. + +pub mod time_chart; +pub use time_chart::*; + +pub mod time_graph; +pub use time_graph::*; + +pub mod text_table; +pub use text_table::*; diff --git a/src/canvas/components/text_table.rs b/src/canvas/components/text_table.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/canvas/components/text_table.rs @@ -0,0 +1 @@ + diff --git a/src/canvas/components/time_chart.rs b/src/canvas/components/time_chart.rs new file mode 100644 index 00000000..5bb0ff15 --- /dev/null +++ b/src/canvas/components/time_chart.rs @@ -0,0 +1,701 @@ +use std::{ + borrow::Cow, + cmp::{max, Ordering}, +}; +use tui::{ + buffer::Buffer, + layout::{Constraint, Rect}, + style::{Color, Style}, + symbols, + text::{Span, Spans}, + widgets::{ + canvas::{Canvas, Line, Points}, + Block, Borders, GraphType, Widget, + }, +}; +use unicode_width::UnicodeWidthStr; + +/// An X or Y axis for the chart widget +#[derive(Debug, Clone)] +pub struct Axis<'a> { + /// Title displayed next to axis end + pub title: Option<Spans<'a>>, + /// Bounds for the axis (all data points outside these limits will not be represented) + pub bounds: [f64; 2], + /// A list of labels to put to the left or below the axis + pub labels: Option<Vec<Span<'a>>>, + /// The style used to draw the axis itself + pub style: Style, +} + +impl<'a> Default for Axis<'a> { + fn default() -> Axis<'a> { + Axis { + title: None, + bounds: [0.0, 0.0], + labels: None, + style: Default::default(), + } + } +} + +#[allow(dead_code)] +impl<'a> Axis<'a> { + pub fn title<T>(mut self, title: T) -> Axis<'a> + where + T: Into<Spans<'a>>, + { + self.title = Some(title.into()); + self + } + + pub fn bounds(mut self, bounds: [f64; 2]) -> Axis<'a> { + self.bounds = bounds; + self + } + + pub fn labels(mut self, labels: Vec<Span<'a>>) -> Axis<'a> { + self.labels = Some(labels); + self + } + + pub fn style(mut self, style: Style) -> Axis<'a> { + self.style = style; + self + } +} + +/// A group of data points +#[derive(Debug, Clone)] +pub struct Dataset<'a> { + /// Name of the dataset (used in the legend if shown) + name: Cow<'a, str>, + /// A reference to the actual data + data: &'a [(f64, f64)], + /// Symbol used for each points of this dataset + marker: symbols::Marker, + /// Determines graph type used for drawing points + graph_type: GraphType, + /// Style used to plot this dataset + style: Style, +} + +impl<'a> Default for Dataset<'a> { + fn default() -> Dataset<'a> { + Dataset { + name: Cow::from(""), + data: &[], + marker: symbols::Marker::Dot, + graph_type: GraphType::Scatter, + style: Style::default(), + } + } +} + +#[allow(dead_code)] +impl<'a> Dataset<'a> { + pub fn name<S>(mut self, name: S) -> Dataset<'a> + where + S: Into<Cow<'a, str>>, + { + self.name = name.into(); + self + } + + pub fn data(mut self, data: &'a [(f64, f64)]) -> Dataset<'a> { + self.data = data; + self + } + + pub fn marker(mut self, marker: symbols::Marker) -> Dataset<'a> { + self.marker = marker; + self + } + + pub fn graph_type(mut self, graph_type: GraphType) -> Dataset<'a> { + self.graph_type = graph_type; + self + } + + pub fn style(mut self, style: Style) -> Dataset<'a> { + self.style = style; + self + } +} + +/// A container that holds all the infos about where to display each elements of the chart (axis, +/// labels, legend, ...). +#[derive(Default, Debug, Clone, PartialEq)] +struct ChartLayout { + /// Location of the title of the x axis + title_x: Option<(u16, u16)>, + /// Location of the title of the y axis + title_y: Option<(u16, u16)>, + /// Location of the first label of the x axis + label_x: Option<u16>, + /// Location of the first label of the y axis + label_y: Option<u16>, + /// Y coordinate of the horizontal axis + axis_x: Option<u16>, + /// X coordinate of the vertical axis + axis_y: Option<u16>, + /// Area of the legend + legend_area: Option<Rect>, + /// Area of the graph + graph_area: Rect, +} + +/// A "custom" chart, just a slightly tweaked [`tui::widgets::Chart`] from tui-rs, but with greater control over the +/// legend, and built with the idea of drawing data points relative to a time-based x-axis. +/// +/// Main changes: +/// - Styling option for the legend box +/// - Automatically trimming out redundant draws in the x-bounds. +/// - Automatic interpolation to points that fall *just* outside of the screen. +/// +/// TODO: Support for putting the legend on the left side. +#[derive(Debug, Clone)] +pub struct TimeChart<'a> { + /// A block to display around the widget eventually + block: Option<Block<'a>>, + /// The horizontal axis + x_axis: Axis<'a>, + /// The vertical axis + y_axis: Axis<'a>, + /// A reference to the datasets + datasets: Vec<Dataset<'a>>, + /// The widget base style + style: Style, + /// The legend's style + legend_style: Style, + /// Constraints used to determine whether the legend should be shown or not + hidden_legend_constraints: (Constraint, Constraint), +} + +pub const DEFAULT_LEGEND_CONSTRAINTS: (Constraint, Constraint) = + (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)); + +#[allow(dead_code)] +impl<'a> TimeChart<'a> { + /// Creates a new [`TimeChart`]. + /// + /// **Note:** `datasets` **must** be sorted! + pub fn new(datasets: Vec<Dataset<'a>>) -> TimeChart<'a> { + TimeChart { + block: None, + x_axis: Axis::default(), + y_axis: Axis::default(), + style: Default::default(), + legend_style: Default::default(), + datasets, + hidden_legend_constraints: DEFAULT_LEGEND_CONSTRAINTS, + } + } + + pub fn block(mut self, block: Block<'a>) -> TimeChart<'a> { + self.block = Some(block); + self + } + + pub fn style(mut self, style: Style) -> TimeChart<'a> { + self.style = style; + self + } + + pub fn legend_style(mut self, legend_style: Style) -> TimeChart<'a> { + self.legend_style = legend_style; + self + } + + pub fn x_axis(mut self, axis: Axis<'a>) -> TimeChart<'a> { + self.x_axis = axis; + self + } + + pub fn y_axis(mut self, axis: Axis<'a>) -> TimeChart<'a> { + self.y_axis = axis; + self + } + + /// Set the constraints used to determine whether the legend should be shown or not. + pub fn hidden_legend_constraints( + mut self, constraints: (Constraint, Constraint), + ) -> TimeChart<'a> { + self.hidden_legend_constraints = constraints; + self + } + + /// Compute the internal layout of the chart given the area. If the area is too small some + /// elements may be automatically hidden + fn layout(&self, area: Rect) -> ChartLayout { + let mut layout = ChartLayout::default(); + if area.height == 0 || area.width == 0 { + return layout; + } + let mut x = area.left(); + let mut y = area.bottom() - 1; + + if self.x_axis.labels.is_some() && y > area.top() { + layout.label_x = Some(y); + y -= 1; + } + + layout.label_y = self.y_axis.labels.as_ref().and(Some(x)); + x += self.max_width_of_labels_left_of_y_axis(area); + + if self.x_axis.labels.is_some() && y > area.top() { + layout.axis_x = Some(y); + y -= 1; + } + + if self.y_axis.labels.is_some() && x + 1 < area.right() { + layout.axis_y = Some(x); + x += 1; + } + + if x < area.right() && y > 1 { + layout.graph_area = Rect::new(x, area.top(), area.right() - x, y - area.top() + 1); + } + + if let Some(ref title) = self.x_axis.title { + let w = title.width() as u16; + if w < layout.graph_area.width && layout.graph_area.height > 2 { + layout.title_x = Some((x + layout.graph_area.width - w, y)); + } + } + + if let Some(ref title) = self.y_axis.title { + let w = title.width() as u16; + if w + 1 < layout.graph_area.width && layout.graph_area.height > 2 { + layout.title_y = Some((x, area.top())); + } + } + + if let Some(inner_width) = self.datasets.iter().map(|d| d.name.width() as u16).max() { + let legend_width = inner_width + 2; + let legend_height = self.datasets.len() as u16 + 2; + let max_legend_width = self + .hidden_legend_constraints + .0 + .apply(layout.graph_area.width); + let max_legend_height = self + .hidden_legend_constraints + .1 + .apply(layout.graph_area.height); + if inner_width > 0 + && legend_width < max_legend_width + && legend_height < max_legend_height + { + layout.legend_area = Some(Rect::new( + layout.graph_area.right() - legend_width, + layout.graph_area.top(), + legend_width, + legend_height, + )); + } + } + layout + } + + fn max_width_of_labels_left_of_y_axis(&self, area: Rect) -> u16 { + let mut max_width = self + .y_axis + .labels + .as_ref() + .map(|l| l.iter().map(Span::width).max().unwrap_or_default() as u16) + .unwrap_or_default(); + if let Some(ref x_labels) = self.x_axis.labels { + if !x_labels.is_empty() { + max_width = max(max_width, x_labels[0].content.width() as u16); + } + } + // labels of y axis and first label of x axis can take at most 1/3rd of the total width + max_width.min(area.width / 3) + } + + fn render_x_labels( + &mut self, buf: &mut Buffer, layout: &ChartLayout, chart_area: Rect, graph_area: Rect, + ) { + let y = match layout.label_x { + Some(y) => y, + None => return, + }; + let labels = self.x_axis.labels.as_ref().unwrap(); + let labels_len = labels.len() as u16; + if labels_len < 2 { + return; + } + let width_between_ticks = graph_area.width / (labels_len - 1); + for (i, label) in labels.iter().enumerate() { + let label_width = label.width() as u16; + let label_width = if i == 0 { + // the first label is put between the left border of the chart and the y axis. + graph_area + .left() + .saturating_sub(chart_area.left()) + .min(label_width) + } else { + // other labels are put on the left of each tick on the x axis + width_between_ticks.min(label_width) + }; + buf.set_span( + graph_area.left() + i as u16 * width_between_ticks - label_width, + y, + label, + label_width, + ); + } + } + + fn render_y_labels( + &mut self, buf: &mut Buffer, layout: &ChartLayout, chart_area: Rect, graph_area: Rect, + ) { + let x = match layout.label_y { + Some(x) => x, + None => return, + }; + let labels = self.y_axis.labels.as_ref().unwrap(); + let labels_len = labels.len() as u16; + let label_width = graph_area.left().saturating_sub(chart_area.left()); + for (i, label) in labels.iter().enumerate() { + let dy = i as u16 * (graph_area.height - 1) / (labels_len - 1); + if dy < graph_area.bottom() { + buf.set_span(x, graph_area.bottom() - 1 - dy, label, label_width as u16); + } + } + } +} + +impl<'a> Widget for TimeChart<'a> { + fn render(mut self, area: Rect, buf: &mut Buffer) { + if area.area() == 0 { + return; + } + buf.set_style(area, self.style); + // Sample the style of the entire widget. This sample will be used to reset the style of + // the cells that are part of the components put on top of the graph area (i.e legend and + // axis names). + let original_style = buf.get(area.left(), area.top()).style(); + + let chart_area = match self.block.take() { + Some(b) => { + let inner_area = b.inner(area); + b.render(area, buf); + inner_area + } + None => area, + }; + + let layout = self.layout(chart_area); + let graph_area = layout.graph_area; + if graph_area.width < 1 || graph_area.height < 1 { + return; + } + + self.render_x_labels(buf, &layout, chart_area, graph_area); + self.render_y_labels(buf, &layout, chart_area, graph_area); + + if let Some(y) = layout.axis_x { + for x in graph_area.left()..graph_area.right() { + buf.get_mut(x, y) + .set_symbol(symbols::line::HORIZONTAL) + .set_style(self.x_axis.style); + } + } + + if let Some(x) = layout.axis_y { + for y in graph_area.top()..graph_area.bottom() { + buf.get_mut(x, y) + .set_symbol(symbols::line::VERTICAL) + .set_style(self.y_axis.style); + } + } + + if let Some(y) = layout.axis_x { + if let Some(x) = layout.axis_y { + buf.get_mut(x, y) + .set_symbol(symbols::line::BOTTOM_LEFT) + .set_style(self.x_axis.style); + } + } + + for dataset in &self.datasets { + Canvas::default() + .background_color(self.style.bg.unwrap_or(Color::Reset)) + .x_bounds(self.x_axis.bounds) + .y_bounds(self.y_axis.bounds) + .marker(dataset.marker) + .paint(|ctx| { + let start_bound = self.x_axis.bounds[0]; + let end_bound = self.x_axis.bounds[1]; + + let (start_index, interpolate_start) = get_start(dataset, start_bound); + let (end_index, interpolate_end) = get_end(dataset, end_bound); + + let data_slice = &dataset.data[start_index..end_index]; + + ctx.draw(&Points { + coords: data_slice, + color: dataset.style.fg.unwrap_or(Color::Reset), + }); + + if let Some(interpolate_start) = interpolate_start { + if let (Some(older_point), Some(newer_point)) = ( + dataset.data.get(interpolate_start), + dataset.data.get(interpolate_start + 1), + ) { + let interpolated_point = ( + self.x_axis.bounds[0], + interpolate_point(older_point, newer_point, self.x_axis.bounds[0]), + ); + + ctx.draw(&Points { + coords: &[interpolated_point], + color: dataset.style.fg.unwrap_or(Color::Reset), + }); + + if let GraphType::Line = dataset.graph_type { + ctx.draw(&Line { + x1: interpolated_point.0, + y1: interpolated_point.1, + x2: newer_point.0, + y2: newer_point.1, + color: dataset.style.fg.unwrap_or(Color::Reset), + }); + } + } + } + + if let GraphType::Line = dataset.graph_type { + for data in data_slice.windows(2) { + ctx.draw(&Line { + x1: data[0].0, + y1: data[0].1, + x2: data[1].0, + y2: data[1].1, + color: dataset.style.fg.unwrap_or(Color::Reset), + }); + } + } + + if let Some(interpolate_end) = interpolate_end { + if let (Some(older_point), Some(newer_point)) = ( + dataset.data.get(interpolate_end - 1), + dataset.data.get(interpolate_end), + ) { + let interpolated_point = ( + self.x_axis.bounds[1], + interpolate_point(older_point, newer_point, self.x_axis.bounds[1]), + ); + + ctx.draw(&Points { + coords: &[interpolated_point], + color: dataset.style.fg.unwrap_or(Color::Reset), + }); + + if let GraphType::Line = dataset.graph_type { + ctx.draw(&Line { + x1: older_point.0, + y1: older_point.1, + x2: interpolated_point.0, + y2: interpolated_point.1, + color: dataset.style.fg.unwrap_or(Color::Reset), + }); + } + } + } + }) + .render(graph_area, buf); + } + + if let Some(legend_area) = layout.legend_area { + buf.set_style(legend_area, original_style); + Block::default() + .borders(Borders::ALL) + .border_style(self.legend_style) + .render(legend_area, buf); + for (i, dataset) in self.datasets.iter().enumerate() { + buf.set_string( + legend_area.x + 1, + legend_area.y + 1 + i as u16, + &dataset.name, + dataset.style, + ); + } + } + + if let Some((x, y)) = layout.title_x { + let title = self.x_axis.title.unwrap(); + let width = graph_area.right().saturating_sub(x); + buf.set_style( + Rect { + x, + y, + width, + height: 1, + }, + original_style, + ); + buf.set_spans(x, y, &title, width); + } + + if let Some((x, y)) = layout.title_y { + let title = self.y_axis.title.unwrap(); + let width = graph_area.right().saturating_sub(x); + buf.set_style( + Rect { + x, + y, + width, + height: 1, + }, + original_style, + ); + buf.set_spans(x, y, &title, width); + } + } +} + +fn bin_cmp(a: &f64, b: &f64) -> Ordering { + // TODO: Switch to `total_cmp` on 1.62 + a.partial_cmp(b).unwrap_or(Ordering::Equal) +} + +/// Returns the start index and potential interpolation index given the start time and the dataset. +fn get_start(dataset: &Dataset<'_>, start_bound: f64) -> (usize, Option<usize>) { + match dataset + .data + .binary_search_by(|(x, _y)| bin_cmp(x, &start_bound)) + { + Ok(index) => (index, None), + Err(index) => (index, index.checked_sub(1)), + } +} + +/// Returns the end position and potential interpolation index given the end time and the dataset. +fn get_end(dataset: &Dataset<'_>, end_bound: f64) -> (usize, Option<usize>) { + match dataset + .data + .binary_search_by(|(x, _y)| bin_cmp(x, &end_bound)) + { + // In the success case, this means we found an index. Add one since we want to include this index and we + // expect to use the returned index as part of a (m..n) range. + Ok(index) => (index.saturating_add(1), None), + // In the fail case, this means we did not find an index, and the returned index is where one would *insert* + // the location. This index is where one would insert to fit inside the dataset - and since this is an end + // bound, index is, in a sense, already "+1" for our range later. + Err(index) => (index, { + let sum = index.checked_add(1); + match sum { + Some(s) if s < dataset.data.len() => sum, + _ => None, + } + }), + } +} + +/// Returns the y-axis value for a given `x`, given two points to draw a line between. +fn interpolate_point(older_point: &(f64, f64), newer_point: &(f64, f64), x: f64) -> f64 { + let delta_x = newer_point.0 - older_point.0; + let delta_y = newer_point.1 - older_point.1; + let slope = delta_y / delta_x; + + (older_point.1 + (x - older_point.0) * slope).max(0.0) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn time_chart_test_interpolation() { + let data = [(-3.0, 8.0), (-1.0, 6.0), (0.0, 5.0)]; + + assert_eq!(interpolate_point(&data[1], &data[2], 0.0), 5.0); + assert_eq!(interpolate_point(&data[1], &data[2], -0.25), 5.25); + assert_eq!(interpolate_point(&data[1], &data[2], -0.5), 5.5); + assert_eq!(interpolate_point(&data[0], &data[1], -1.0), 6.0); + assert_eq!(interpolate_point(&data[0], &data[1], -1.5), 6.5); + assert_eq!(interpolate_point(&data[0], &data[1], -2.0), 7.0); + assert_eq!(interpolate_point(&data[0], &data[1], -2.5), 7.5); + assert_eq!(interpolate_point(&data[0], &data[1], -3.0), 8.0); + } + + #[test] + fn time_chart_test_data_trimming() { + // Quick test on a completely empty dataset... + { + let data = []; + let dataset = Dataset::default().data(&data); + + assert_eq!(get_start(&dataset, -100.0), (0, None)); + assert_eq!(get_start(&dataset, -3.0), (0, None)); + + assert_eq!(get_end(&dataset, 0.0), (0, None)); + assert_eq!(get_end(&dataset, 100.0), (0, None)); + } + + let data = [ + (-3.0, 8.0), + (-2.5, 15.0), + (-2.0, 9.0), + (-1.0, 6.0), + (0.0, 5.0), + ]; + let dataset = Dataset::default().data(&data); + + // Test start point cases (miss and hit) + assert_eq!(get_start(&dataset, -100.0), (0, None)); + assert_eq!(get_start(&dataset, -3.0), (0, None)); + assert_eq!(get_start(&dataset, -2.8), (1, Some(0))); + assert_eq!(get_start(&dataset, -2.5), (1, None)); + assert_eq!(get_start(&dataset, -2.4), (2, Some(1))); + + // Test end point cases (miss and hit) + assert_eq!(get_end(&dataset, -2.5), (2, None)); + assert_eq!(get_end(&dataset, -2.4), (2, Some(3))); + assert_eq!(get_end(&dataset, -1.4), (3, Some(4))); + assert_eq!(get_end(&dataset, -1.0), (4, None)); + assert_eq!(get_end(&dataset, 0.0), (5, None)); + assert_eq!(get_end(&dataset, 1.0), (5, None)); + assert_eq!(get_end(&dataset, 100.0), (5, None)); + } + + struct LegendTestCase { + chart_area: Rect, + hidden_legend_constraints: (Constraint, Constraint), + legend_area: Option<Rect>, + } + + /// Test from the original tui-rs [`Chart`](tui::widgets::Chart). + #[test] + fn it_should_hide_the_legend() { + let data = [(0.0, 5.0), (1.0, 6.0), (3.0, 7.0)]; + let cases = [ + LegendTestCase { + chart_area: Rect::new(0, 0, 100, 100), + hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)), + legend_area: Some(Rect::new(88, 0, 12, 12)), + }, + LegendTestCase { + chart_area: Rect::new(0, 0, 100, 100), + hidden_legend_constraints: (Constraint::Ratio(1, 10), Constraint::Ratio(1, 4)), + legend_area: None, + }, + ]; + for case in &cases { + let datasets = (0..10) + .map(|i| { + let name = format!("Dataset #{}", i); + Dataset::default().name(name).data(&data) + }) + .collect::<Vec<_>>(); + let chart = TimeChart::new(datasets) + .x_axis(Axis::default().title("X axis")) + .y_axis(Axis::default().title("Y axis")) + .hidden_legend_constraints(case.hidden_legend_constraints); + let layout = chart.layout(case.chart_area); + assert_eq!(layout.legend_area, case.legend_area); + } + } +} diff --git a/src/canvas/components/time_graph.rs b/src/canvas/components/time_graph.rs new file mode 100644 index 00000000..ab5cec2e --- /dev/null +++ b/src/canvas/components/time_graph.rs @@ -0,0 +1,273 @@ +use std::borrow::Cow; + +use tui::{ + backend::Backend, + layout::{Constraint, Rect}, + style::Style, + symbols::Marker, + text::{Span, Spans}, + widgets::{Block, Borders, GraphType}, + Frame, +}; + +use concat_string::concat_string; +use unicode_segmentation::UnicodeSegmentation; + +use super::{Axis, Dataset, TimeChart}; + +/// A single graph point. +pub type Point = (f64, f64); + +/// Represents the data required by the [`TimeGraph`]. +pub struct GraphData<'a> { + pub points: &'a [Point], + pub style: Style, + pub name: Option<Cow<'a, str>>, +} + +#[derive(Default)] +pub struct TimeGraph<'a> { + /// Whether to use a dot marker over the default braille markers. + pub use_dot: bool, + + /// 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)>, +} + +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 x_labels = vec![ + Span::raw(concat_string!((self.x_bounds[1] / 1000).to_string(), "s")), + Span::raw(concat_string!((self.x_bounds[0] / 1000).to_string(), "s")), + ]; + + 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::raw(label.clone())) + .collect(), + ) + } + + /// Generates a title for the [`TimeGraph`] widget, given the available space. + fn generate_title(&self, draw_loc: Rect) -> Spans<'_> { + if self.is_expanded { + let title_base = concat_string!(self.title, "── Esc to go back "); + Spans::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 { + Spans::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<B: Backend>( + &self, f: &mut Frame<'_, B>, 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. + let data = if self.use_dot { + graph_data + .iter() + .map(|data| create_dataset(data, Marker::Dot)) + .collect() + } else { + graph_data + .iter() + .map(|data| create_dataset(data, Marker::Braille)) + .collect() + }; + + f.render_widget( + TimeChart::new(data) + .block( + Block::default() + .title(self.generate_title(draw_loc)) + .borders(Borders::ALL) + .border_style(self.border_style), + ) + .x_axis(x_axis) + .y_axis(y_axis) + .hidden_legend_constraints( + self.legend_constraints + .unwrap_or(super::DEFAULT_LEGEND_CONSTRAINTS), + ), + draw_loc, + ) + } +} + +/// Creates a new [`Dataset`]. +fn create_dataset<'a>(data: &'a GraphData<'a>, marker: Marker) -> Dataset<'a> { + let GraphData { + points, + style, + name, + } = data; + + let dataset = Dataset::default() + .style(*style) + .data(points) + .graph_type(GraphType::Line) + .marker(marker); + + if let Some(name) = name { + dataset.name(name.as_ref()) + } else { + dataset + } +} + +#[cfg(test)] +mod test { + use std::borrow::Cow; + + use tui::{ |