summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock7
-rw-r--r--Cargo.toml1
-rw-r--r--src/canvas.rs16
-rw-r--r--src/canvas/components.rs10
-rw-r--r--src/canvas/components/text_table.rs1
-rw-r--r--src/canvas/components/time_chart.rs701
-rw-r--r--src/canvas/components/time_graph.rs273
-rw-r--r--src/canvas/drawing_utils.rs222
-rw-r--r--src/canvas/widgets/cpu_graph.rs309
-rw-r--r--src/canvas/widgets/mem_graph.rs258
-rw-r--r--src/canvas/widgets/network_graph.rs972
11 files changed, 1726 insertions, 1044 deletions
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",
@@ -355,6 +356,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
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<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_da