diff --git a/Cargo.lock b/Cargo.lock index 44ad380a..9f99ec6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,6 +232,7 @@ dependencies = [ "clap", "clap_complete", "clap_mangen", + "concat-string", "crossterm", "ctrlc", "dirs", @@ -354,6 +355,12 @@ dependencies = [ "roff", ] +[[package]] +name = "concat-string" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7439becb5fafc780b6f4de382b1a7a3e70234afe783854a4702ee8adbb838609" + [[package]] name = "concurrent-queue" version = "1.2.2" diff --git a/Cargo.toml b/Cargo.toml index c9185740..df91de95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ crossterm = "0.18.2" ctrlc = { version = "3.1.9", features = ["termination"] } clap = { version = "3.1.12", features = ["default", "cargo", "wrap_help"] } cfg-if = "1.0.0" +concat-string = "1.0.1" dirs = "4.0.0" futures = "0.3.21" futures-timer = "3.0.2" 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>, + /// 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>>, + /// 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(mut self, title: T) -> Axis<'a> + where + T: Into>, + { + 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>) -> 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(mut self, name: S) -> Dataset<'a> + where + S: Into>, + { + 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, + /// Location of the first label of the y axis + label_y: Option, + /// Y coordinate of the horizontal axis + axis_x: Option, + /// X coordinate of the vertical axis + axis_y: Option, + /// Area of the legend + legend_area: Option, + /// 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>, + /// The horizontal axis + x_axis: Axis<'a>, + /// The vertical axis + y_axis: Axis<'a>, + /// A reference to the datasets + datasets: Vec>, + /// 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>) -> 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) { + 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) { + 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, + } + + /// 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::>(); + 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>, +} + +#[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( + &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::{ + layout::Rect, + style::{Color, Style}, + text::{Span, Spans}, + }; + + use crate::canvas::components::Axis; + + use super::TimeGraph; + + 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(), + use_dot: true, + 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, + } + } + + #[test] + fn time_graph_gen_x_axis() { + let tg = create_time_graph(); + + let x_axis = tg.generate_x_axis(); + let actual = Axis::default() + .bounds([-15000.0, 0.0]) + .labels(vec![Span::raw("15s"), Span::raw("0s")]) + .style(Style::default().fg(Color::Red)); + 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 y_axis = tg.generate_y_axis(); + let actual = Axis::default() + .bounds([0.0, 100.5]) + .labels(vec![Span::raw("0%"), Span::raw("50%"), Span::raw("100%")]) + .style(Style::default().fg(Color::Red)); + + 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 tg = create_time_graph(); + let draw_loc = Rect::new(0, 0, 32, 100); + + let title = tg.generate_title(draw_loc); + assert_eq!( + title, + Spans::from(Span::styled(" Network ", Style::default().fg(Color::Cyan))) + ); + + tg.is_expanded = true; + let title = tg.generate_title(draw_loc); + assert_eq!( + title, + Spans::from(vec![ + Span::styled(" Network ", Style::default().fg(Color::Cyan)), + Span::styled("───── Esc to go back ", Style::default().fg(Color::Blue)) + ]) + ); + } +} diff --git a/src/canvas/drawing_utils.rs b/src/canvas/drawing_utils.rs index 7cdb3eb5..3b4fc3f6 100644 --- a/src/canvas/drawing_utils.rs +++ b/src/canvas/drawing_utils.rs @@ -1,5 +1,10 @@ +use tui::layout::Rect; + use crate::app; -use std::cmp::{max, min}; +use std::{ + cmp::{max, min}, + time::Instant, +}; /// Return a (hard)-width vector for column widths. /// @@ -186,8 +191,7 @@ pub fn get_start_position( } } -/// Calculate how many bars are to be -/// drawn within basic mode's components. +/// Calculate how many bars are to be drawn within basic mode's components. pub fn calculate_basic_use_bars(use_percentage: f64, num_bars_available: usize) -> usize { std::cmp::min( (num_bars_available as f64 * use_percentage / 100.0).round() as usize, @@ -195,21 +199,214 @@ pub fn calculate_basic_use_bars(use_percentage: f64, num_bars_available: usize) ) } -/// Interpolates between two points. Mainly used to help fill in tui-rs blanks in certain situations. -/// It is expected point_one is "further left" compared to point_two. -/// A point is two floats, in (x, y) form. x is time, y is value. -pub fn interpolate_points(point_one: &(f64, f64), point_two: &(f64, f64), time: f64) -> f64 { - let delta_x = point_two.0 - point_one.0; - let delta_y = point_two.1 - point_one.1; - let slope = delta_y / delta_x; +/// Determine whether a graph x-label should be hidden. +pub fn should_hide_x_label( + always_hide_time: bool, autohide_time: bool, timer: &mut Option, draw_loc: Rect, +) -> bool { + use crate::constants::*; - (point_one.1 + (time - point_one.0) * slope).max(0.0) + if always_hide_time || (autohide_time && timer.is_none()) { + true + } else if let Some(time) = timer { + if Instant::now().duration_since(*time).as_millis() < AUTOHIDE_TIMEOUT_MILLISECONDS.into() { + false + } else { + *timer = None; + true + } + } else { + draw_loc.height < TIME_LABEL_HEIGHT_LIMIT + } } #[cfg(test)] mod test { + use super::*; + #[test] + fn test_get_start_position() { + use crate::app::ScrollDirection; + + // Scrolling down from start + { + let mut bar = 0; + assert_eq!( + get_start_position(10, &ScrollDirection::Down, &mut bar, 0, false), + 0 + ); + assert_eq!(bar, 0); + } + + // Simple scrolling down + { + let mut bar = 0; + assert_eq!( + get_start_position(10, &ScrollDirection::Down, &mut bar, 1, false), + 0 + ); + assert_eq!(bar, 0); + } + + // Scrolling down from the middle high up + { + let mut bar = 0; + assert_eq!( + get_start_position(10, &ScrollDirection::Down, &mut bar, 5, false), + 0 + ); + assert_eq!(bar, 0); + } + + // Scrolling down into boundary + { + let mut bar = 0; + assert_eq!( + get_start_position(10, &ScrollDirection::Down, &mut bar, 11, false), + 1 + ); + assert_eq!(bar, 1); + } + + // Scrolling down from the with non-zero bar + { + let mut bar = 5; + assert_eq!( + get_start_position(10, &ScrollDirection::Down, &mut bar, 15, false), + 5 + ); + assert_eq!(bar, 5); + } + + // Force redraw scrolling down (e.g. resize) + { + let mut bar = 5; + assert_eq!( + get_start_position(15, &ScrollDirection::Down, &mut bar, 15, true), + 0 + ); + assert_eq!(bar, 0); + } + + // Test jumping down + { + let mut bar = 1; + assert_eq!( + get_start_position(10, &ScrollDirection::Down, &mut bar, 20, true), + 10 + ); + assert_eq!(bar, 10); + } + + // Scrolling up from bottom + { + let mut bar = 10; + assert_eq!( + get_start_position(10, &ScrollDirection::Up, &mut bar, 20, false), + 10 + ); + assert_eq!(bar, 10); + } + + // Simple scrolling up + { + let mut bar = 10; + assert_eq!( + get_start_position(10, &ScrollDirection::Up, &mut bar, 19, false), + 10 + ); + assert_eq!(bar, 10); + } + + // Scrolling up from the middle + { + let mut bar = 10; + assert_eq!( + get_start_position(10, &ScrollDirection::Up, &mut bar, 10, false), + 10 + ); + assert_eq!(bar, 10); + } + + // Scrolling up into boundary + { + let mut bar = 10; + assert_eq!( + get_start_position(10, &ScrollDirection::Up, &mut bar, 9, false), + 9 + ); + assert_eq!(bar, 9); + } + + // Force redraw scrolling up (e.g. resize) + { + let mut bar = 5; + assert_eq!( + get_start_position(10, &ScrollDirection::Up, &mut bar, 15, true), + 5 + ); + assert_eq!(bar, 5); + } + + // Test jumping up + { + let mut bar = 10; + assert_eq!( + get_start_position(10, &ScrollDirection::Up, &mut bar, 0, false), + 0 + ); + assert_eq!(bar, 0); + } + } + + #[test] + fn test_calculate_basic_use_bars() { + // Testing various breakpoints and edge cases. + assert_eq!(calculate_basic_use_bars(0.0, 15), 0); + assert_eq!(calculate_basic_use_bars(1.0, 15), 0); + assert_eq!(calculate_basic_use_bars(5.0, 15), 1); + assert_eq!(calculate_basic_use_bars(10.0, 15), 2); + assert_eq!(calculate_basic_use_bars(40.0, 15), 6); + assert_eq!(calculate_basic_use_bars(45.0, 15), 7); + assert_eq!(calculate_basic_use_bars(50.0, 15), 8); + assert_eq!(calculate_basic_use_bars(100.0, 15), 15); + assert_eq!(calculate_basic_use_bars(150.0, 15), 15); + } + + #[test] + fn test_should_hide_x_label() { + use crate::constants::*; + use std::time::{Duration, Instant}; + use tui::layout::Rect; + + let rect = Rect::new(0, 0, 10, 10); + let small_rect = Rect::new(0, 0, 10, 6); + + let mut under_timer = Some(Instant::now()); + let mut over_timer = + Instant::now().checked_sub(Duration::from_millis(AUTOHIDE_TIMEOUT_MILLISECONDS + 100)); + + assert!(should_hide_x_label(true, false, &mut None, rect)); + assert!(should_hide_x_label(false, true, &mut None, rect)); + assert!(should_hide_x_label(false, false, &mut None, small_rect)); + + assert!(!should_hide_x_label( + false, + true, + &mut under_timer, + small_rect + )); + assert!(under_timer.is_some()); + + assert!(should_hide_x_label( + false, + true, + &mut over_timer, + small_rect + )); + assert!(over_timer.is_none()); + } + #[test] fn test_zero_width() { assert_eq!( @@ -222,7 +419,6 @@ mod test { true ), vec![], - "vector should be empty" ); } @@ -238,7 +434,6 @@ mod test { true ), vec![], - "vector should be empty" ); } @@ -254,7 +449,6 @@ mod test { true ), vec![2, 2, 7], - "vector should not be empty" ); } } diff --git a/src/canvas/widgets/cpu_graph.rs b/src/canvas/widgets/cpu_graph.rs index bee3e30c..2847e5b3 100644 --- a/src/canvas/widgets/cpu_graph.rs +++ b/src/canvas/widgets/cpu_graph.rs @@ -1,36 +1,32 @@ -use once_cell::sync::Lazy; -use unicode_segmentation::UnicodeSegmentation; +use std::borrow::Cow; use crate::{ app::{layout_manager::WidgetDirection, App}, canvas::{ - drawing_utils::{get_column_widths, get_start_position, interpolate_points}, + components::{GraphData, TimeGraph}, + drawing_utils::{get_column_widths, get_start_position, should_hide_x_label}, Painter, }, constants::*, data_conversion::ConvertedCpuData, }; +use concat_string::concat_string; + use tui::{ backend::Backend, layout::{Constraint, Direction, Layout, Rect}, - symbols::Marker, terminal::Frame, - text::Span, - text::{Spans, Text}, - widgets::{Axis, Block, Borders, Chart, Dataset, Row, Table}, + text::Text, + widgets::{Block, Borders, Row, Table}, }; const CPU_LEGEND_HEADER: [&str; 2] = ["CPU", "Use%"]; const AVG_POSITION: usize = 1; const ALL_POSITION: usize = 0; -static CPU_LEGEND_HEADER_LENS: Lazy> = Lazy::new(|| { - CPU_LEGEND_HEADER - .iter() - .map(|entry| entry.len() as u16) - .collect::>() -}); +static CPU_LEGEND_HEADER_LENS: [usize; 2] = + [CPU_LEGEND_HEADER[0].len(), CPU_LEGEND_HEADER[1].len()]; impl Painter { pub fn draw_cpu( @@ -122,250 +118,93 @@ impl Painter { fn draw_cpu_graph( &self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64, ) { + const Y_BOUNDS: [f64; 2] = [0.0, 100.5]; + const Y_LABELS: [Cow<'static, str>; 2] = [Cow::Borrowed(" 0%"), Cow::Borrowed("100%")]; + if let Some(cpu_widget_state) = app_state.cpu_state.widget_states.get_mut(&widget_id) { - let cpu_data: &mut [ConvertedCpuData] = &mut app_state.canvas_data.cpu_data; - - let display_time_labels = vec![ - Span::styled( - format!("{}s", cpu_widget_state.current_display_time / 1000), - self.colours.graph_style, - ), - Span::styled("0s".to_string(), self.colours.graph_style), - ]; - - let y_axis_labels = vec![ - Span::styled(" 0%", self.colours.graph_style), - Span::styled("100%", self.colours.graph_style), - ]; - - let time_start = -(cpu_widget_state.current_display_time as f64); - - let x_axis = if app_state.app_config_fields.hide_time - || (app_state.app_config_fields.autohide_time - && cpu_widget_state.autohide_timer.is_none()) - { - Axis::default().bounds([time_start, 0.0]) - } else if let Some(time) = cpu_widget_state.autohide_timer { - if std::time::Instant::now().duration_since(time).as_millis() - < AUTOHIDE_TIMEOUT_MILLISECONDS.into() - { - Axis::default() - .bounds([time_start, 0.0]) - .style(self.colours.graph_style) - .labels(display_time_labels) - } else { - cpu_widget_state.autohide_timer = None; - Axis::default().bounds([time_start, 0.0]) - } - } else if draw_loc.height < TIME_LABEL_HEIGHT_LIMIT { - Axis::default().bounds([time_start, 0.0]) - } else { - Axis::default() - .bounds([time_start, 0.0]) - .style(self.colours.graph_style) - .labels(display_time_labels) - }; - - let y_axis = Axis::default() - .style(self.colours.graph_style) - .bounds([0.0, 100.5]) - .labels(y_axis_labels); - - let use_dot = app_state.app_config_fields.use_dot; + let cpu_data = &app_state.canvas_data.cpu_data; + let border_style = self.get_border_style(widget_id, app_state.current_widget.widget_id); + let x_bounds = [0, cpu_widget_state.current_display_time]; + let hide_x_labels = should_hide_x_label( + app_state.app_config_fields.hide_time, + app_state.app_config_fields.autohide_time, + &mut cpu_widget_state.autohide_timer, + draw_loc, + ); let show_avg_cpu = app_state.app_config_fields.show_average_cpu; - let current_scroll_position = cpu_widget_state.scroll_state.current_scroll_position; - - let interpolated_cpu_points = cpu_data - .iter_mut() - .enumerate() - .map(|(itx, cpu)| { - let to_show = if current_scroll_position == ALL_POSITION { - true - } else { - itx == current_scroll_position - }; - - if to_show { - if let Some(end_pos) = cpu - .cpu_data - .iter() - .position(|(time, _data)| *time >= time_start) - { - if end_pos > 1 { - let start_pos = end_pos - 1; - let outside_point = cpu.cpu_data.get(start_pos); - let inside_point = cpu.cpu_data.get(end_pos); - - if let (Some(outside_point), Some(inside_point)) = - (outside_point, inside_point) - { - let old = *outside_point; - - let new_point = ( - time_start, - interpolate_points(outside_point, inside_point, time_start), - ); - - if let Some(to_replace) = cpu.cpu_data.get_mut(start_pos) { - *to_replace = new_point; - Some((start_pos, old)) - } else { - None // Failed to get mutable reference. - } - } else { - None // Point somehow doesn't exist in our data - } - } else { - None // Point is already "leftmost", no need to interpolate. - } - } else { - None // There is no point. - } - } else { - None - } - }) - .collect::>(); - - let dataset_vector: Vec> = if current_scroll_position == ALL_POSITION { - cpu_data - .iter() - .enumerate() - .rev() - .map(|(itx, cpu)| { - Dataset::default() - .marker(if use_dot { - Marker::Dot - } else { - Marker::Braille - }) - .style(if show_avg_cpu && itx == AVG_POSITION { + let show_avg_offset = if show_avg_cpu { AVG_POSITION } else { 0 }; + let points = { + let current_scroll_position = cpu_widget_state.scroll_state.current_scroll_position; + if current_scroll_position == ALL_POSITION { + // This case ensures the other cases cannot have the position be equal to 0. + cpu_data + .iter() + .enumerate() + .rev() + .map(|(itx, cpu)| { + let style = if show_avg_cpu && itx == AVG_POSITION { self.colours.avg_colour_style } else if itx == ALL_POSITION { self.colours.all_colour_style } else { - self.colours.cpu_colour_styles[(itx - 1 // Because of the all position - - (if show_avg_cpu { - AVG_POSITION - } else { - 0 - })) + let offset_position = itx - 1; // Because of the all position + self.colours.cpu_colour_styles[(offset_position - show_avg_offset) % self.colours.cpu_colour_styles.len()] - }) - .data(&cpu.cpu_data[..]) - .graph_type(tui::widgets::GraphType::Line) - }) - .collect() - } else if let Some(cpu) = cpu_data.get(current_scroll_position) { - vec![Dataset::default() - .marker(if use_dot { - Marker::Dot - } else { - Marker::Braille - }) - .style(if show_avg_cpu && current_scroll_position == AVG_POSITION { + }; + + GraphData { + points: &cpu.cpu_data[..], + style, + name: None, + } + }) + .collect::>() + } else if let Some(cpu) = cpu_data.get(current_scroll_position) { + let style = if show_avg_cpu && current_scroll_position == AVG_POSITION { self.colours.avg_colour_style } else { - self.colours.cpu_colour_styles[(cpu_widget_state - .scroll_state - .current_scroll_position - - 1 // Because of the all position - - (if show_avg_cpu { - AVG_POSITION - } else { - 0 - })) + let offset_position = current_scroll_position - 1; // Because of the all position + self.colours.cpu_colour_styles[(offset_position - show_avg_offset) % self.colours.cpu_colour_styles.len()] - }) - .data(&cpu.cpu_data[..]) - .graph_type(tui::widgets::GraphType::Line)] - } else { - vec![] - }; - - let is_on_widget = widget_id == app_state.current_widget.widget_id; - let border_style = if is_on_widget { - self.colours.highlighted_border_style - } else { - self.colours.border_style + }; + + vec![GraphData { + points: &cpu.cpu_data[..], + style, + name: None, + }] + } else { + vec![] + } }; + // TODO: Maybe hide load avg if too long? Or maybe the CPU part. let title = if cfg!(target_family = "unix") { let load_avg = app_state.canvas_data.load_avg_data; let load_avg_str = format!( "─ {:.2} {:.2} {:.2} ", load_avg[0], load_avg[1], load_avg[2] ); - let load_avg_str_size = - UnicodeSegmentation::graphemes(load_avg_str.as_str(), true).count(); - if app_state.is_expanded { - const TITLE_BASE: &str = " CPU ── Esc to go back "; - - Spans::from(vec![ - Span::styled(" CPU ", self.colours.widget_title_style), - Span::styled(load_avg_str, self.colours.widget_title_style), - Span::styled( - format!( - "─{}─ Esc to go back ", - "─".repeat(usize::from(draw_loc.width).saturating_sub( - load_avg_str_size - + UnicodeSegmentation::graphemes(TITLE_BASE, true).count() - + 2 - )) - ), - border_style, - ), - ]) - } else { - Spans::from(vec![ - Span::styled(" CPU ", self.colours.widget_title_style), - Span::styled(load_avg_str, self.colours.widget_title_style), - ]) - } - } else if app_state.is_expanded { - const TITLE_BASE: &str = " CPU ── Esc to go back "; - - Spans::from(vec![ - Span::styled(" CPU ", self.colours.widget_title_style), - Span::styled( - format!( - "─{}─ Esc to go back ", - "─".repeat(usize::from(draw_loc.width).saturating_sub( - UnicodeSegmentation::graphemes(TITLE_BASE, true).count() + 2 - )) - ), - border_style, - ), - ]) + concat_string!(" CPU ", load_avg_str).into() } else { - Spans::from(vec![Span::styled(" CPU ", self.colours.widget_title_style)]) + " CPU ".into() }; - f.render_widget( - Chart::new(dataset_vector) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ) - .x_axis(x_axis) - .y_axis(y_axis), - draw_loc, - ); - - // Reset interpolated points - cpu_data - .iter_mut() - .zip(interpolated_cpu_points) - .for_each(|(cpu, interpolation)| { - if let Some((index, old_value)) = interpolation { - if let Some(to_replace) = cpu.cpu_data.get_mut(index) { - *to_replace = old_value; - } - } - }); + TimeGraph { + use_dot: app_state.app_config_fields.use_dot, + x_bounds, + hide_x_labels, + y_bounds: Y_BOUNDS, + y_labels: &Y_LABELS, + graph_style: self.colours.graph_style, + border_style, + title, + is_expanded: app_state.is_expanded, + title_style: self.colours.widget_title_style, + legend_constraints: None, + } + .draw_time_graph(f, draw_loc, &points); } } @@ -416,7 +255,7 @@ impl Painter { &[None, None], &(CPU_LEGEND_HEADER_LENS .iter() - .map(|width| Some(*width)) + .map(|width| Some(*width as u16)) .collect::>()), &[Some(0.5), Some(0.5)], &(cpu_widget_state diff --git a/src/canvas/widgets/mem_graph.rs b/src/canvas/widgets/mem_graph.rs index efb1f341..e5d0fbcc 100644 --- a/src/canvas/widgets/mem_graph.rs +++ b/src/canvas/widgets/mem_graph.rs @@ -1,234 +1,72 @@ +use std::borrow::Cow; + use crate::{ app::App, - canvas::{drawing_utils::interpolate_points, Painter}, - constants::*, + canvas::{ + components::{GraphData, TimeGraph}, + drawing_utils::should_hide_x_label, + Painter, + }, }; use tui::{ backend::Backend, layout::{Constraint, Rect}, - symbols::Marker, terminal::Frame, - text::Span, - text::Spans, - widgets::{Axis, Block, Borders, Chart, Dataset}, }; -use unicode_segmentation::UnicodeSegmentation; impl Painter { pub fn draw_memory_graph( &self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64, ) { + const Y_BOUNDS: [f64; 2] = [0.0, 100.5]; + const Y_LABELS: [Cow<'static, str>; 2] = [Cow::Borrowed(" 0%"), Cow::Borrowed("100%")]; + if let Some(mem_widget_state) = app_state.mem_state.widget_states.get_mut(&widget_id) { - let mem_data: &mut [(f64, f64)] = &mut app_state.canvas_data.mem_data; - let swap_data: &mut [(f64, f64)] = &mut app_state.canvas_data.swap_data; - - let time_start = -(mem_widget_state.current_display_time as f64); - - let display_time_labels = vec![ - Span::styled( - format!("{}s", mem_widget_state.current_display_time / 1000), - self.colours.graph_style, - ), - Span::styled("0s".to_string(), self.colours.graph_style), - ]; - let y_axis_label = vec![ - Span::styled(" 0%", self.colours.graph_style), - Span::styled("100%", self.colours.graph_style), - ]; - - let x_axis = if app_state.app_config_fields.hide_time - || (app_state.app_config_fields.autohide_time - && mem_widget_state.autohide_timer.is_none()) - { - Axis::default().bounds([time_start, 0.0]) - } else if let Some(time) = mem_widget_state.autohide_timer { - if std::time::Instant::now().duration_since(time).as_millis() - < AUTOHIDE_TIMEOUT_MILLISECONDS.into() - { - Axis::default() - .bounds([time_start, 0.0]) - .style(self.colours.graph_style) - .labels(display_time_labels) - } else { - mem_widget_state.autohide_timer = None; - Axis::default().bounds([time_start, 0.0]) - } - } else if draw_loc.height < TIME_LABEL_HEIGHT_LIMIT { - Axis::default().bounds([time_start, 0.0]) - } else { - Axis::default() - .bounds([time_start, 0.0]) - .style(self.colours.graph_style) - .labels(display_time_labels) - }; - - let y_axis = Axis::default() - .style(self.colours.graph_style) - .bounds([0.0, 100.5]) - .labels(y_axis_label); - - // Interpolate values to avoid ugly gaps - let interpolated_mem_point = if let Some(end_pos) = mem_data - .iter() - .position(|(time, _data)| *time >= time_start) - { - if end_pos > 1 { - let start_pos = end_pos - 1; - let outside_point = mem_data.get(start_pos); - let inside_point = mem_data.get(end_pos); - - if let (Some(outside_point), Some(inside_point)) = (outside_point, inside_point) - { - let old = *outside_point; - - let new_point = ( - time_start, - interpolate_points(outside_point, inside_point, time_start), - ); - - if let Some(to_replace) = mem_data.get_mut(start_pos) { - *to_replace = new_point; - Some((start_pos, old)) - } else { - None // Failed to get mutable reference. - } - } else { - None // Point somehow doesn't exist in our data - } - } else { - None // Point is already "leftmost", no need to interpolate. - } - } else { - None // There is no point. - }; - - let interpolated_swap_point = if let Some(end_pos) = swap_data - .iter() - .position(|(time, _data)| *time >= time_start) - { - if end_pos > 1 { - let start_pos = end_pos - 1; - let outside_point = swap_data.get(start_pos); - let inside_point = swap_data.get(end_pos); - - if let (Some(outside_point), Some(inside_point)) = (outside_point, inside_point) - { - let old = *outside_point; - - let new_point = ( - time_start, - interpolate_points(outside_point, inside_point, time_start), - ); - - if let Some(to_replace) = swap_data.get_mut(start_pos) { - *to_replace = new_point; - Some((start_pos, old)) - } else { - None // Failed to get mutable reference. - } - } else { - None // Point somehow doesn't exist in our data - } - } else { - None // Point is already "leftmost", no need to interpolate. - } - } else { - None // There is no point. - }; - - let mut mem_canvas_vec: Vec> = vec![]; - - if let Some((label_percent, label_frac)) = &app_state.canvas_data.mem_labels { - let mem_label = format!("RAM:{}{}", label_percent, label_frac); - mem_canvas_vec.push( - Dataset::default() - .name(mem_label) - .marker(if app_state.app_config_fields.use_dot { - Marker::Dot - } else { - Marker::Braille - }) - .style(self.colours.ram_style) - .data(mem_data) - .graph_type(tui::widgets::GraphType::Line), - ); - } - - if let Some((label_percent, label_frac)) = &app_state.canvas_data.swap_labels { - let swap_label = format!("SWP:{}{}", label_percent, label_frac); - mem_canvas_vec.push( - Dataset::default() - .name(swap_label) - .marker(if app_state.app_config_fields.use_dot { - Marker::Dot - } else { - Marker::Braille - }) - .style(self.colours.swap_style) - .data(swap_data) - .graph_type(tui::widgets::GraphType::Line), - ); - } - - let is_on_widget = widget_id == app_state.current_widget.widget_id; - let border_style = if is_on_widget { - self.colours.highlighted_border_style - } else { - self.colours.border_style - }; - - let title = if app_state.is_expanded { - const TITLE_BASE: &str = " Memory ── Esc to go back "; - Spans::from(vec![ - Span::styled(" Memory ", self.colours.widget_title_style), - Span::styled( - format!( - "─{}─ Esc to go back ", - "─".repeat(usize::from(draw_loc.width).saturating_sub( - UnicodeSegmentation::graphemes(TITLE_BASE, true).count() + 2 - )) - ), - border_style, - ), - ]) - } else { - Spans::from(Span::styled( - " Memory ".to_string(), - self.colours.widget_title_style, - )) - }; - - f.render_widget( - Chart::new(mem_canvas_vec) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(if app_state.current_widget.widget_id == widget_id { - self.colours.highlighted_border_style - } else { - self.colours.border_style - }), - ) - .x_axis(x_axis) - .y_axis(y_axis) - .hidden_legend_constraints((Constraint::Ratio(3, 4), Constraint::Ratio(3, 4))), + let border_style = self.get_border_style(widget_id, app_state.current_widget.widget_id); + let x_bounds = [0, mem_widget_state.current_display_time]; + let hide_x_labels = should_hide_x_label( + app_state.app_config_fields.hide_time, + app_state.app_config_fields.autohide_time, + &mut mem_widget_state.autohide_timer, draw_loc, ); - - // Now if you're done, reset any interpolated points! - if let Some((index, old_value)) = interpolated_mem_point { - if let Some(to_replace) = mem_data.get_mut(index) { - *to_replace = old_value; + let points = { + let mut points = Vec::with_capacity(2); + if let Some((label_percent, label_frac)) = &app_state.canvas_data.mem_labels { + let mem_label = format!("RAM:{}{}", label_percent, label_frac); + points.push(GraphData { + points: &app_state.canvas_data.mem_data, + style: self.colours.ram_style, + name: Some(mem_label.into()), + }); } - } - - if let Some((index, old_value)) = interpolated_swap_point { - if let Some(to_replace) = swap_data.get_mut(index) { - *to_replace = old_value; + if let Some((label_percent, label_frac)) = &app_state.canvas_data.swap_labels { + let swap_label = format!("SWP:{}{}", label_percent, label_frac); + points.push(GraphData { + points: &app_state.canvas_data.swap_data, + style: self.colours.swap_style, + name: Some(swap_label.into()), + }); } + + points + }; + + TimeGraph { + use_dot: app_state.app_config_fields.use_dot, + x_bounds, + hide_x_labels, + y_bounds: Y_BOUNDS, + y_labels: &Y_LABELS, + graph_style: self.colours.graph_style, + border_style, + title: " Memory ".into(), + is_expanded: app_state.is_expanded, + title_style: self.colours.widget_title_style, + legend_constraints: Some((Constraint::Ratio(3, 4), Constraint::Ratio(3, 4))), } + .draw_time_graph(f, draw_loc, &points); } if app_state.should_get_widget_bounds() { diff --git a/src/canvas/widgets/network_graph.rs b/src/canvas/widgets/network_graph.rs index 3c0aaadb..2c3256f0 100644 --- a/src/canvas/widgets/network_graph.rs +++ b/src/canvas/widgets/network_graph.rs @@ -1,12 +1,12 @@ use once_cell::sync::Lazy; use std::cmp::max; -use unicode_segmentation::UnicodeSegmentation; use crate::{ app::{App, AxisScaling}, canvas::{ - drawing_utils::{get_column_widths, interpolate_points}, - Painter, + components::{GraphData, TimeGraph}, + drawing_utils::{get_column_widths, should_hide_x_label}, + Painter, Point, }, constants::*, units::data_units::DataUnit, @@ -16,11 +16,9 @@ use crate::{ use tui::{ backend::Backend, layout::{Constraint, Direction, Layout, Rect}, - symbols::Marker, terminal::Frame, - text::Span, - text::{Spans, Text}, - widgets::{Axis, Block, Borders, Chart, Dataset, Row, Table}, + text::Text, + widgets::{Block, Borders, Row, Table}, }; const NETWORK_HEADERS: [&str; 4] = ["RX", "TX", "Total RX", "Total TX"]; @@ -68,462 +66,18 @@ impl Painter { &self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64, hide_legend: bool, ) { - /// Point is of time, data - type Point = (f64, f64); - - /// Returns the max data point and time given a time. - fn get_max_entry( - rx: &[Point], tx: &[Point], time_start: f64, network_scale_type: &AxisScaling, - network_use_binary_prefix: bool, - ) -> (f64, f64) { - /// Determines a "fake" max value in circumstances where we couldn't find one from the data. - fn calculate_missing_max( - network_scale_type: &AxisScaling, network_use_binary_prefix: bool, - ) -> f64 { - match network_scale_type { - AxisScaling::Log => { - if network_use_binary_prefix { - LOG_KIBI_LIMIT - } else { - LOG_KILO_LIMIT - } - } - AxisScaling::Linear => { - if network_use_binary_prefix { - KIBI_LIMIT_F64 - } else { - KILO_LIMIT_F64 - } - } - } - } - - // First, let's shorten our ranges to actually look. We can abuse the fact that our rx and tx arrays - // are sorted, so we can short-circuit our search to filter out only the relevant data points... - let filtered_rx = if let (Some(rx_start), Some(rx_end)) = ( - rx.iter().position(|(time, _data)| *time >= time_start), - rx.iter().rposition(|(time, _data)| *time <= 0.0), - ) { - Some(&rx[rx_start..=rx_end]) - } else { - None - }; - - let filtered_tx = if let (Some(tx_start), Some(tx_end)) = ( - tx.iter().position(|(time, _data)| *time >= time_start), - tx.iter().rposition(|(time, _data)| *time <= 0.0), - ) { - Some(&tx[tx_start..=tx_end]) - } else { - None - }; - - // Then, find the maximal rx/tx so we know how to scale, and return it. - match (filtered_rx, filtered_tx) { - (None, None) => ( - time_start, - calculate_missing_max(network_scale_type, network_use_binary_prefix), - ), - (None, Some(filtered_tx)) => { - match filtered_tx - .iter() - .max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false)) - { - Some((best_time, max_val)) => { - if *max_val == 0.0 { - ( - time_start, - calculate_missing_max( - network_scale_type, - network_use_binary_prefix, - ), - ) - } else { - (*best_time, *max_val) - } - } - None => ( - time_start, - calculate_missing_max(network_scale_type, network_use_binary_prefix), - ), - } - } - (Some(filtered_rx), None) => { - match filtered_rx - .iter() - .max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false)) - { - Some((best_time, max_val)) => { - if *max_val == 0.0 { - ( - time_start, - calculate_missing_max( - network_scale_type, - network_use_binary_prefix, - ), - ) - } else { - (*best_time, *max_val) - } - } - None => ( - time_start, - calculate_missing_max(network_scale_type, network_use_binary_prefix), - ), - } - } - (Some(filtered_rx), Some(filtered_tx)) => { - match filtered_rx - .iter() - .chain(filtered_tx) - .max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false)) - { - Some((best_time, max_val)) => { - if *max_val == 0.0 { - ( - *best_time, - calculate_missing_max( - network_scale_type, - network_use_binary_prefix, - ), - ) - } else { - (*best_time, *max_val) - } - } - None => ( - time_start, - calculate_missing_max(network_scale_type, network_use_binary_prefix), - ), - } - } - } - } - - /// Returns the required max data point and labels. - fn adjust_network_data_point( - max_entry: f64, network_scale_type: &AxisScaling, network_unit_type: &DataUnit, - network_use_binary_prefix: bool, - ) -> (f64, Vec) { - // So, we're going with an approach like this for linear data: - // - Main goal is to maximize the amount of information displayed given a specific height. - // We don't want to drown out some data if the ranges are too far though! Nor do we want to filter - // out too much data... - // - Change the y-axis unit (kilo/kibi, mega/mebi...) dynamically based on max load. - // - // The idea is we take the top value, build our scale such that each "point" is a scaled version of that. - // So for example, let's say I use 390 Mb/s. If I drew 4 segments, it would be 97.5, 195, 292.5, 390, and - // probably something like 438.75? - // - // So, how do we do this in tui-rs? Well, if we are using intervals that tie in perfectly to the max - // value we want... then it's actually not that hard. Since tui-rs accepts a vector as labels and will - // properly space them all out... we just work with that and space it out properly. - // - // Dynamic chart idea based off of FreeNAS's chart design. - // - // === - // - // For log data, we just use the old method of log intervals (kilo/mega/giga/etc.). Keep it nice and simple. - - // Now just check the largest unit we correspond to... then proceed to build some entries from there! - - let unit_char = match network_unit_type { - DataUnit::Byte => "B", - DataUnit::Bit => "b", - }; - - match network_scale_type { - AxisScaling::Linear => { - let (k_limit, m_limit, g_limit, t_limit) = if network_use_binary_prefix { - ( - KIBI_LIMIT_F64, - MEBI_LIMIT_F64, - GIBI_LIMIT_F64, - TEBI_LIMIT_F64, - ) - } else { - ( - KILO_LIMIT_F64, - MEGA_LIMIT_F64, - GIGA_LIMIT_F64, - TERA_LIMIT_F64, - ) - }; - - let bumped_max_entry = max_entry * 1.5; // We use the bumped up version to calculate our unit type. - let (max_value_scaled, unit_prefix, unit_type): (f64, &str, &str) = - if bumped_max_entry < k_limit { - (max_entry, "", unit_char) - } else if bumped_max_entry < m_limit { - ( - max_entry / k_limit, - if network_use_binary_prefix { "Ki" } else { "K" }, - unit_char, - ) - } else if bumped_max_entry < g_limit { - ( - max_entry / m_limit, - if network_use_binary_prefix { "Mi" } else { "M" }, - unit_char, - ) - } else if bumped_max_entry < t_limit { - ( - max_entry / g_limit, - if network_use_binary_prefix { "Gi" } else { "G" }, - unit_char, - ) - } else { - ( - max_entry / t_limit, - if network_use_binary_prefix { "Ti" } else { "T" }, - unit_char, - ) - }; - - // Finally, build an acceptable range starting from there, using the given height! - // Note we try to put more of a weight on the bottom section vs. the top, since the top has less data. - - let base_unit = max_value_scaled; - let labels: Vec = vec![ - format!("0{}{}", unit_prefix, unit_type), - format!("{:.1}", base_unit * 0.5), - format!("{:.1}", base_unit), - format!("{:.1}", base_unit * 1.5), - ] - .into_iter() - .map(|s| format!("{:>5}", s)) // Pull 5 as the longest legend value is generally going to be 5 digits (if they somehow hit over 5 terabits per second) - .collect(); - - (bumped_max_entry, labels) - } - AxisScaling::Log => { - let (m_limit, g_limit, t_limit) = if network_use_binary_prefix { - (LOG_MEBI_LIMIT, LOG_GIBI_LIMIT, LOG_TEBI_LIMIT) - } else { - (LOG_MEGA_LIMIT, LOG_GIGA_LIMIT, LOG_TERA_LIMIT) - }; - - fn get_zero(network_use_binary_prefix: bool, unit_char: &str) -> String { - format!( - "{}0{}", - if network_use_binary_prefix { " " } else { " " }, - unit_char - ) - } - - fn get_k(network_use_binary_prefix: bool, unit_char: &str) -> String { - format!( - "1{}{}", - if network_use_binary_prefix { "Ki" } else { "K" }, - unit_char - ) - } - - fn get_m(network_use_binary_prefix: bool, unit_char: &str) -> String { - format!( - "1{}{}", - if network_use_binary_prefix { "Mi" } else { "M" }, - unit_char - ) - } - - fn get_g(network_use_binary_prefix: bool, unit_char: &str) -> String { - format!( - "1{}{}", - if network_use_binary_prefix { "Gi" } else { "G" }, - unit_char - ) - } - - fn get_t(network_use_binary_prefix: bool, unit_char: &str) -> String { - format!( - "1{}{}", - if network_use_binary_prefix { "Ti" } else { "T" }, - unit_char - ) - } - - fn get_p(network_use_binary_prefix: bool, unit_char: &str) -> String { - format!( - "1{}{}", - if network_use_binary_prefix { "Pi" } else { "P" }, - unit_char - ) - } - - if max_entry < m_limit { - ( - m_limit, - vec![ - get_zero(network_use_binary_prefix, unit_char), - get_k(network_use_binary_prefix, unit_char), - get_m(network_use_binary_prefix, unit_char), - ], - ) - } else if max_entry < g_limit { - ( - g_limit, - vec![ - get_zero(network_use_binary_prefix, unit_char), - get_k(network_use_binary_prefix, unit_char), - get_m(network_use_binary_prefix, unit_char), - get_g(network_use_binary_prefix, unit_char), - ], - ) - } else if max_entry < t_limit { - ( - t_limit, - vec![ - get_zero(network_use_binary_prefix, unit_char), - get_k(network_use_binary_prefix, unit_char), - get_m(network_use_binary_prefix, unit_char), - get_g(network_use_binary_prefix, unit_char), - get_t(network_use_binary_prefix, unit_char), - ], - ) - } else { - // I really doubt anyone's transferring beyond petabyte speeds... - ( - if network_use_binary_prefix { - LOG_PEBI_LIMIT - } else { - LOG_PETA_LIMIT - }, - vec![ - get_zero(network_use_binary_prefix, unit_char), - get_k(network_use_binary_prefix, unit_char), - get_m(network_use_binary_prefix, unit_char), - get_g(network_use_binary_prefix, unit_char), - get_t(network_use_binary_prefix, unit_char), - get_p(network_use_binary_prefix, unit_char), - ], - ) - } - } - } - } - if let Some(network_widget_state) = app_state.net_state.widget_states.get_mut(&widget_id) { - let network_data_rx: &mut [(f64, f64)] = &mut app_state.canvas_data.network_data_rx; - let network_data_tx: &mut [(f64, f64)] = &mut app_state.canvas_data.network_data_tx; - + let network_data_rx: &[(f64, f64)] = &app_state.canvas_data.network_data_rx; + let network_data_tx: &[(f64, f64)] = &app_state.canvas_data.network_data_tx; let time_start = -(network_widget_state.current_display_time as f64); - - let display_time_labels = vec![ - Span::styled( - format!("{}s", network_widget_state.current_display_time / 1000), - self.colours.graph_style, - ), - Span::styled("0s".to_string(), self.colours.graph_style), - ]; - let x_axis = if app_state.app_config_fields.hide_time - || (app_state.app_config_fields.autohide_time - && network_widget_state.autohide_timer.is_none()) - { - Axis::default().bounds([time_start, 0.0]) - } else if let Some(time) = network_widget_state.autohide_timer { - if std::time::Instant::now().duration_since(time).as_millis() - < AUTOHIDE_TIMEOUT_MILLISECONDS.into() - { - Axis::default() - .bounds([time_start, 0.0]) - .style(self.colours.graph_style) - .labels(display_time_labels) - } else { - network_widget_state.autohide_timer = None; - Axis::default().bounds([time_start, 0.0]) - } - } else if draw_loc.height < TIME_LABEL_HEIGHT_LIMIT { - Axis::default().bounds([time_start, 0.0]) - } else { - Axis::default() - .bounds([time_start, 0.0]) - .style(self.colours.graph_style) - .labels(display_time_labels) - }; - - // Interpolate a point for rx and tx between the last value outside of the left bounds and the first value - // inside it. - // Because we assume it is all in order for... basically all our code, we can't just append it, - // and insertion in the middle seems. So instead, we swap *out* the value that is outside with our - // interpolated point, draw and do whatever calculations, then swap back in the old value! - // - // Note there is some re-used work here! For potential optimizations, we could re-use some work here in/from - // get_max_entry... - let interpolated_rx_point = if let Some(rx_end_pos) = network_data_rx - .iter() - .position(|(time, _data)| *time >= time_start) - { - if rx_end_pos > 1 { - let rx_start_pos = rx_end_pos - 1; - let outside_rx_point = network_data_rx.get(rx_start_pos); - let inside_rx_point = network_data_rx.get(rx_end_pos); - - if let (Some(outside_rx_point), Some(inside_rx_point)) = - (outside_rx_point, inside_rx_point) - { - let old = *outside_rx_point; - - let new_point = ( - time_start, - interpolate_points(outside_rx_point, inside_rx_point, time_start), - ); - - // debug!( - // "Interpolated between {:?} and {:?}, got rx for time {:?}: {:?}", - // outside_rx_point, inside_rx_point, time_start, new_point - // ); - - if let Some(to_replace) = network_data_rx.get_mut(rx_start_pos) { - *to_replace = new_point; - Some((rx_start_pos, old)) - } else { - None // Failed to get mutable reference. - } - } else { - None // Point somehow doesn't exist in our network_data_rx - } - } else { - None // Point is already "leftmost", no need to interpolate. - } - } else { - None // There is no point. - }; - - let interpolated_tx_point = if let Some(tx_end_pos) = network_data_tx - .iter() - .position(|(time, _data)| *time >= time_start) - { - if tx_end_pos > 1 { - let tx_start_pos = tx_end_pos - 1; - let outside_tx_point = network_data_tx.get(tx_start_pos); - let inside_tx_point = network_data_tx.get(tx_end_pos); - - if let (Some(outside_tx_point), Some(inside_tx_point)) = - (outside_tx_point, inside_tx_point) - { - let old = *outside_tx_point; - - let new_point = ( - time_start, - interpolate_points(outside_tx_point, inside_tx_point, time_start), - ); - - if let Some(to_replace) = network_data_tx.get_mut(tx_start_pos) { - *to_replace = new_point; - Some((tx_start_pos, old)) - } else { - None // Failed to get mutable reference. - } - } else { - None // Point somehow doesn't exist in our network_data_tx - } - } else { - None // Point is already "leftmost", no need to interpolate. - } - } else { - None // There is no point. - }; + let border_style = self.get_border_style(widget_id, app_state.current_widget.widget_id); + let x_bounds = [0, network_widget_state.current_display_time]; + let hide_x_labels = should_hide_x_label( + app_state.app_config_fields.hide_time, + app_state.app_config_fields.autohide_time, + &mut network_widget_state.autohide_timer, + draw_loc, + ); // TODO: Cache network results: Only update if: // - Force update (includes time interval change) @@ -531,7 +85,6 @@ impl Painter { // - A new time interval is better and does not fit (check from end of vector to last checked; we only want to update if it is TOO big!) // Find the maximal rx/tx so we know how to scale, and return it. - let (_best_time, max_entry) = get_max_entry( network_data_rx, network_data_tx, @@ -547,44 +100,8 @@ impl Painter { app_state.app_config_fields.network_use_binary_prefix, ); - // Cache results. - // network_widget_state.draw_max_range_cache = max_range; - // network_widget_state.draw_time_start_cache = best_time; - // network_widget_state.draw_labels_cache = labels; - - let y_axis_labels = labels - .iter() - .map(|label| Span::styled(label, self.colours.graph_style)) - .collect::>(); - let y_axis = Axis::default() - .style(self.colours.graph_style) - .bounds([0.0, max_range]) - .labels(y_axis_labels); - - let is_on_widget = widget_id == app_state.current_widget.widget_id; - let border_style = if is_on_widget { - self.colours.highlighted_border_style - } else { - self.colours.border_style - }; - - let title = if app_state.is_expanded { - const TITLE_BASE: &str = " Network ── Esc to go back "; - Spans::from(vec![ - Span::styled(" Network ", self.colours.widget_title_style), - Span::styled( - format!( - "─{}─ Esc to go back ", - "─".repeat(usize::from(draw_loc.width).saturating_sub( - UnicodeSegmentation::graphemes(TITLE_BASE, true).count() + 2 - )) - ), - border_style, - ), - ]) - } else { - Spans::from(Span::styled(" Network ", self.colours.widget_title_style)) - }; + let y_labels = labels.iter().map(|label| label.into()).collect::>(); + let y_bounds = [0.0, max_range]; let legend_constraints = if hide_legend { (Constraint::Ratio(0, 1), Constraint::Ratio(0, 1)) @@ -593,96 +110,64 @@ impl Painter { }; // TODO: Add support for clicking on legend to only show that value on chart. - let dataset = if app_state.app_config_fields.use_old_network_legend && !hide_legend { + let points = if app_state.app_config_fields.use_old_network_legend && !hide_legend { vec![ - Dataset::default() - .name(format!("RX: {:7}", app_state.canvas_data.rx_display)) - .marker(if app_state.app_config_fields.use_dot { - Marker::Dot - } else { - Marker::Braille - }) - .style(self.colours.rx_style) - .data(network_data_rx) - .graph_type(tui::widgets::GraphType::Line), - Dataset::default() - .name(format!("TX: {:7}", app_state.canvas_data.tx_display)) - .marker(if app_state.app_config_fields.use_dot { - Marker::Dot - } else { - Marker::Braille - }) - .style(self.colours.tx_style) - .data(network_data_tx) - .graph_type(tui::widgets::GraphType::Line), - Dataset::default() - .name(format!( - "Total RX: {:7}", - app_state.canvas_data.total_rx_display - )) - .style(self.colours.total_rx_style), - Dataset::default() - .name(format!( - "Total TX: {:7}", - app_state.canvas_data.total_tx_display - )) - .style(self.colours.total_tx_style), + GraphData { + points: network_data_rx, + style: self.colours.rx_style, + name: Some(format!("RX: {:7}", app_state.canvas_data.rx_display).into()), + }, + GraphData { + points: network_data_tx, + style: self.colours.tx_style, + name: Some(format!("TX: {:7}", app_state.canvas_data.tx_display).into()), + }, + GraphData { + points: &[], + style: self.colours.total_rx_style, + name: Some( + format!("Total RX: {:7}", app_state.canvas_data.total_rx_display) + .into(), + ), + }, + GraphData { + points: &[], + style: self.colours.total_tx_style, + name: Some( + format!("Total TX: {:7}", app_state.canvas_data.total_tx_display) + .into(), + ), + }, ] } else { vec![ - Dataset::default() - .name(&app_state.canvas_data.rx_display) - .marker(if app_state.app_config_fields.use_dot { - Marker::Dot - } else { - Marker::Braille - }) - .style(self.colours.rx_style) - .data(network_data_rx) - .graph_type(tui::widgets::GraphType::Line), - Dataset::default() - .name(&app_state.canvas_data.tx_display) - .marker(if app_state.app_config_fields.use_dot { - Marker::Dot - } else { - Marker::Braille - }) - .style(self.colours.tx_style) - .data(network_data_tx) - .graph_type(tui::widgets::GraphType::Line), + GraphData { + points: network_data_rx, + style: self.colours.rx_style, + name: Some((&app_state.canvas_data.rx_display).into()), + }, + GraphData { + points: network_data_tx, + style: self.colours.tx_style, + name: Some((&app_state.canvas_data.tx_display).into()), + }, ] }; - f.render_widget( - Chart::new(dataset) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(if app_state.current_widget.widget_id == widget_id { - self.colours.highlighted_border_style - } else { - self.colours.border_style - }), - ) - .x_axis(x_axis) - .y_axis(y_axis) - .hidden_legend_constraints(legend_constraints), - draw_loc, - ); - - // Now if you're done, reset any interpolated points! - if let Some((index, old_value)) = interpolated_rx_point { - if let Some(to_replace) = network_data_rx.get_mut(index) { - *to_replace = old_value; - } - } - - if let Some((index, old_value)) = interpolated_tx_point { - if let Some(to_replace) = network_data_tx.get_mut(index) { - *to_replace = old_value; - } + TimeGraph { + use_dot: app_state.app_config_fields.use_dot, + x_bounds, + hide_x_labels, + y_bounds, + y_labels: &y_labels, + graph_style: self.colours.graph_style, + border_style, + title: " Network ".into(), + is_expanded: app_state.is_expanded, + title_style: self.colours.widget_title_style, + legend_constraints: Some(legend_constraints), } + .draw_time_graph(f, draw_loc, &points); } } @@ -753,3 +238,326 @@ impl Painter { ); } } + +/// Returns the max data point and time given a time. +fn get_max_entry( + rx: &[Point], tx: &[Point], time_start: f64, network_scale_type: &AxisScaling, + network_use_binary_prefix: bool, +) -> (f64, f64) { + /// Determines a "fake" max value in circumstances where we couldn't find one from the data. + fn calculate_missing_max( + network_scale_type: &AxisScaling, network_use_binary_prefix: bool, + ) -> f64 { + match network_scale_type { + AxisScaling::Log => { + if network_use_binary_prefix { + LOG_KIBI_LIMIT + } else { + LOG_KILO_LIMIT + } + } + AxisScaling::Linear => { + if network_use_binary_prefix { + KIBI_LIMIT_F64 + } else { + KILO_LIMIT_F64 + } + } + } + } + + // First, let's shorten our ranges to actually look. We can abuse the fact that our rx and tx arrays + // are sorted, so we can short-circuit our search to filter out only the relevant data points... + let filtered_rx = if let (Some(rx_start), Some(rx_end)) = ( + rx.iter().position(|(time, _data)| *time >= time_start), + rx.iter().rposition(|(time, _data)| *time <= 0.0), + ) { + Some(&rx[rx_start..=rx_end]) + } else { + None + }; + + let filtered_tx = if let (Some(tx_start), Some(tx_end)) = ( + tx.iter().position(|(time, _data)| *time >= time_start), + tx.iter().rposition(|(time, _data)| *time <= 0.0), + ) { + Some(&tx[tx_start..=tx_end]) + } else { + None + }; + + // Then, find the maximal rx/tx so we know how to scale, and return it. + match (filtered_rx, filtered_tx) { + (None, None) => ( + time_start, + calculate_missing_max(network_scale_type, network_use_binary_prefix), + ), + (None, Some(filtered_tx)) => { + match filtered_tx + .iter() + .max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false)) + { + Some((best_time, max_val)) => { + if *max_val == 0.0 { + ( + time_start, + calculate_missing_max(network_scale_type, network_use_binary_prefix), + ) + } else { + (*best_time, *max_val) + } + } + None => ( + time_start, + calculate_missing_max(network_scale_type, network_use_binary_prefix), + ), + } + } + (Some(filtered_rx), None) => { + match filtered_rx + .iter() + .max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false)) + { + Some((best_time, max_val)) => { + if *max_val == 0.0 { + ( + time_start, + calculate_missing_max(network_scale_type, network_use_binary_prefix), + ) + } else { + (*best_time, *max_val) + } + } + None => ( + time_start, + calculate_missing_max(network_scale_type, network_use_binary_prefix), + ), + } + } + (Some(filtered_rx), Some(filtered_tx)) => { + match filtered_rx + .iter() + .chain(filtered_tx) + .max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false)) + { + Some((best_time, max_val)) => { + if *max_val == 0.0 { + ( + *best_time, + calculate_missing_max(network_scale_type, network_use_binary_prefix), + ) + } else { + (*best_time, *max_val) + } + } + None => ( + time_start, + calculate_missing_max(network_scale_type, network_use_binary_prefix), + ), + } + } + } +} + +/// Returns the required max data point and labels. +fn adjust_network_data_point( + max_entry: f64, network_scale_type: &AxisScaling, network_unit_type: &DataUnit, + network_use_binary_prefix: bool, +) -> (f64, Vec) { + // So, we're going with an approach like this for linear data: + // - Main goal is to maximize the amount of information displayed given a specific height. + // We don't want to drown out some data if the ranges are too far though! Nor do we want to filter + // out too much data... + // - Change the y-axis unit (kilo/kibi, mega/mebi...) dynamically based on max load. + // + // The idea is we take the top value, build our scale such that each "point" is a scaled version of that. + // So for example, let's say I use 390 Mb/s. If I drew 4 segments, it would be 97.5, 195, 292.5, 390, and + // probably something like 438.75? + // + // So, how do we do this in tui-rs? Well, if we are using intervals that tie in perfectly to the max + // value we want... then it's actually not that hard. Since tui-rs accepts a vector as labels and will + // properly space them all out... we just work with that and space it out properly. + // + // Dynamic chart idea based off of FreeNAS's chart design. + // + // === + // + // For log data, we just use the old method of log intervals (kilo/mega/giga/etc.). Keep it nice and simple. + + // Now just check the largest unit we correspond to... then proceed to build some entries from there! + + let unit_char = match network_unit_type { + DataUnit::Byte => "B", + DataUnit::Bit => "b", + }; + + match network_scale_type { + AxisScaling::Linear => { + let (k_limit, m_limit, g_limit, t_limit) = if network_use_binary_prefix { + ( + KIBI_LIMIT_F64, + MEBI_LIMIT_F64, + GIBI_LIMIT_F64, + TEBI_LIMIT_F64, + ) + } else { + ( + KILO_LIMIT_F64, + MEGA_LIMIT_F64, + GIGA_LIMIT_F64, + TERA_LIMIT_F64, + ) + }; + + let bumped_max_entry = max_entry * 1.5; // We use the bumped up version to calculate our unit type. + let (max_value_scaled, unit_prefix, unit_type): (f64, &str, &str) = + if bumped_max_entry < k_limit { + (max_entry, "", unit_char) + } else if bumped_max_entry < m_limit { + ( + max_entry / k_limit, + if network_use_binary_prefix { "Ki" } else { "K" }, + unit_char, + ) + } else if bumped_max_entry < g_limit { + ( + max_entry / m_limit, + if network_use_binary_prefix { "Mi" } else { "M" }, + unit_char, + ) + } else if bumped_max_entry < t_limit { + ( + max_entry / g_limit, + if network_use_binary_prefix { "Gi" } else { "G" }, + unit_char, + ) + } else { + ( + max_entry / t_limit, + if network_use_binary_prefix { "Ti" } else { "T" }, + unit_char, + ) + }; + + // Finally, build an acceptable range starting from there, using the given height! + // Note we try to put more of a weight on the bottom section vs. the top, since the top has less data. + + let base_unit = max_value_scaled; + let labels: Vec = vec![ + format!("0{}{}", unit_prefix, unit_type), + format!("{:.1}", base_unit * 0.5), + format!("{:.1}", base_unit), + format!("{:.1}", base_unit * 1.5), + ] + .into_iter() + .map(|s| format!("{:>5}", s)) // Pull 5 as the longest legend value is generally going to be 5 digits (if they somehow hit over 5 terabits per second) + .collect(); + + (bumped_max_entry, labels) + } + AxisScaling::Log => { + let (m_limit, g_limit, t_limit) = if network_use_binary_prefix { + (LOG_MEBI_LIMIT, LOG_GIBI_LIMIT, LOG_TEBI_LIMIT) + } else { + (LOG_MEGA_LIMIT, LOG_GIGA_LIMIT, LOG_TERA_LIMIT) + }; + + fn get_zero(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "{}0{}", + if network_use_binary_prefix { " " } else { " " }, + unit_char + ) + } + + fn get_k(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "1{}{}", + if network_use_binary_prefix { "Ki" } else { "K" }, + unit_char + ) + } + + fn get_m(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "1{}{}", + if network_use_binary_prefix { "Mi" } else { "M" }, + unit_char + ) + } + + fn get_g(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "1{}{}", + if network_use_binary_prefix { "Gi" } else { "G" }, + unit_char + ) + } + + fn get_t(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "1{}{}", + if network_use_binary_prefix { "Ti" } else { "T" }, + unit_char + ) + } + + fn get_p(network_use_binary_prefix: bool, unit_char: &str) -> String { + format!( + "1{}{}", + if network_use_binary_prefix { "Pi" } else { "P" }, + unit_char + ) + } + + if max_entry < m_limit { + ( + m_limit, + vec![ + get_zero(network_use_binary_prefix, unit_char), + get_k(network_use_binary_prefix, unit_char), + get_m(network_use_binary_prefix, unit_char), + ], + ) + } else if max_entry < g_limit { + ( + g_limit, + vec![ + get_zero(network_use_binary_prefix, unit_char), + get_k(network_use_binary_prefix, unit_char), + get_m(network_use_binary_prefix, unit_char), + get_g(network_use_binary_prefix, unit_char), + ], + ) + } else if max_entry < t_limit { + ( + t_limit, + vec![ + get_zero(network_use_binary_prefix, unit_char), + get_k(network_use_binary_prefix, unit_char), + get_m(network_use_binary_prefix, unit_char), + get_g(network_use_binary_prefix, unit_char), + get_t(network_use_binary_prefix, unit_char), + ], + ) + } else { + // I really doubt anyone's transferring beyond petabyte speeds... + ( + if network_use_binary_prefix { + LOG_PEBI_LIMIT + } else { + LOG_PETA_LIMIT + }, + vec![ + get_zero(network_use_binary_prefix, unit_char), + get_k(network_use_binary_prefix, unit_char), + get_m(network_use_binary_prefix, unit_char), + get_g(network_use_binary_prefix, unit_char), + get_t(network_use_binary_prefix, unit_char), + get_p(network_use_binary_prefix, unit_char), + ], + ) + } + } + } +}