refactor: consolidate time graph components

This consolidates all the time graph drawing to one main location, as well
as some small improvements. This is helpful in that I don't have to
reimplement the same thing across three locations if I have to make one
change that in theory should affect them all. In particular, the CPU
graph, memory graph, and network graph are all now using the same,
generic implementation for drawing, which we call (for now) a component.

Note this only affects drawing - it accepts some parameters affecting style
and labels, as well as data points, and draw similarly to how it used to
before. Widget-specific actions, or things affecting widget state,
should all be handled by the widget-specific code instead. For example,
our current implementation of x-axis autohide is still controlled by the
widget, not the component, even if some of the code is shared. Components
are, again, only responsible for drawing (at least for now). For that
matter, the graph component does not have mutable access to any form of
state outside of tui-rs' `Frame`. Note this *might* change in the
future, where we might give the component state.

Note that while functionally, the graph behaviour for now is basically
the same, a few changes were made internally other than the move to
components. The big change is that rather than using tui-rs' `Chart`
for the underlying drawing, we now use a tweaked custom `TimeChart`
tui-rs widget, which also handles all interpolation steps and some extra
customization. Personally, I don't like having to deviate from the
library's implementation, but this gives us more flexibility and allows
greater control. For example, this allows me to move away from the old
hacks required to do interpolation (where I had to mutate the existing
list to avoid having to reallocate an extra vector just to insert one
extra interpolated point). I can also finally allow customizable
legends (which will be added in the future).
This commit is contained in:
ClementTsang 2022-04-27 02:13:48 -04:00
parent 1f731358ba
commit 2401e583fb
11 changed files with 1728 additions and 1046 deletions

7
Cargo.lock generated
View File

@ -232,6 +232,7 @@ dependencies = [
"clap", "clap",
"clap_complete", "clap_complete",
"clap_mangen", "clap_mangen",
"concat-string",
"crossterm", "crossterm",
"ctrlc", "ctrlc",
"dirs", "dirs",
@ -354,6 +355,12 @@ dependencies = [
"roff", "roff",
] ]
[[package]]
name = "concat-string"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7439becb5fafc780b6f4de382b1a7a3e70234afe783854a4702ee8adbb838609"
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "1.2.2" version = "1.2.2"

View File

@ -42,6 +42,7 @@ crossterm = "0.18.2"
ctrlc = { version = "3.1.9", features = ["termination"] } ctrlc = { version = "3.1.9", features = ["termination"] }
clap = { version = "3.1.12", features = ["default", "cargo", "wrap_help"] } clap = { version = "3.1.12", features = ["default", "cargo", "wrap_help"] }
cfg-if = "1.0.0" cfg-if = "1.0.0"
concat-string = "1.0.1"
dirs = "4.0.0" dirs = "4.0.0"
futures = "0.3.21" futures = "0.3.21"
futures-timer = "3.0.2" futures-timer = "3.0.2"

View File

@ -25,14 +25,14 @@ use crate::{
Pid, Pid,
}; };
pub use self::components::Point;
mod canvas_colours; mod canvas_colours;
mod components;
mod dialogs; mod dialogs;
mod drawing_utils; mod drawing_utils;
mod widgets; mod widgets;
/// Point is of time, data
type Point = (f64, f64);
#[derive(Default)] #[derive(Default)]
pub struct DisplayableData { pub struct DisplayableData {
pub rx_display: String, pub rx_display: String,
@ -201,6 +201,16 @@ impl Painter {
Ok(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<()> { fn generate_config_colours(&mut self, config: &Config) -> anyhow::Result<()> {
if let Some(colours) = &config.colors { if let Some(colours) = &config.colors {
self.colours.set_colours_from_palette(colours)?; self.colours.set_colours_from_palette(colours)?;

10
src/canvas/components.rs Normal file
View File

@ -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::*;

View File

@ -0,0 +1 @@

View File

@ -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);
}
}
}

View File

@ -0,0 +1,273 @@
use std::borrow::Cow;
use tui::{
backend::Backend,
layout::{Constraint, Rect},
style::Style,
symbols::Marker,
text::{Span, Spans},
widgets::{Block, Borders, GraphType},
Frame,
};
use concat_string::concat_string;
use unicode_segmentation::UnicodeSegmentation;
use super::{Axis, Dataset, TimeChart};
/// A single graph point.
pub type Point = (f64, f64);
/// Represents the data required by the [`TimeGraph`].
pub struct GraphData<'a> {
pub points: &'a [Point],
pub style: Style,
pub name: Option<Cow<'a, str>>,
}
#[derive(Default)]
pub struct TimeGraph<'a> {
/// Whether to use a dot marker over the default braille markers.
pub use_dot: bool,
/// The min and max x boundaries. Expects a f64 representing the time range in milliseconds.
pub x_bounds: [u64; 2],
/// Whether to hide the time/x-labels.
pub hide_x_labels: bool,
/// The min and max y boundaries.
pub y_bounds: [f64; 2],
/// Any y-labels.
pub y_labels: &'a [Cow<'a, str>],
/// The graph style.
pub graph_style: Style,
/// The border style.
pub border_style: Style,
/// The graph title.
pub title: Cow<'a, str>,
/// Whether this graph is expanded.
pub is_expanded: bool,
/// The title style.
pub title_style: Style,
/// Any legend constraints.
pub legend_constraints: Option<(Constraint, Constraint)>,
}
impl<'a> TimeGraph<'a> {
/// Generates the [`Axis`] for the x-axis.
fn generate_x_axis(&self) -> Axis<'_> {
// Due to how we display things, we need to adjust the time bound values.
let time_start = -(self.x_bounds[1] as f64);
let adjusted_x_bounds = [time_start, 0.0];
if self.hide_x_labels {
Axis::default().bounds(adjusted_x_bounds)
} else {
let x_labels = vec![
Span::raw(concat_string!((self.x_bounds[1] / 1000).to_string(), "s")),
Span::raw(concat_string!((self.x_bounds[0] / 1000).to_string(), "s")),
];
Axis::default()
.bounds(adjusted_x_bounds)
.labels(x_labels)
.style(self.graph_style)
}
}
/// Generates the [`Axis`] for the y-axis.
fn generate_y_axis(&self) -> Axis<'_> {
Axis::default()
.bounds(self.y_bounds)
.style(self.graph_style)
.labels(
self.y_labels
.iter()
.map(|label| Span::raw(label.clone()))
.collect(),
)
}
/// Generates a title for the [`TimeGraph`] widget, given the available space.
fn generate_title(&self, draw_loc: Rect) -> Spans<'_> {
if self.is_expanded {
let title_base = concat_string!(self.title, "── Esc to go back ");
Spans::from(vec![
Span::styled(self.title.as_ref(), self.title_style),
Span::styled(
concat_string!(
"",
"".repeat(usize::from(draw_loc.width).saturating_sub(
UnicodeSegmentation::graphemes(title_base.as_str(), true).count() + 2
)),
"─ Esc to go back "
),
self.border_style,
),
])
} else {
Spans::from(Span::styled(self.title.as_ref(), self.title_style))
}
}
/// Draws a time graph at [`Rect`] location provided by `draw_loc`. A time graph is used to display data points
/// throughout time in the x-axis.
///
/// This time graph:
/// - Draws with the higher time value on the left, and lower on the right.
/// - Expects a [`TimeGraph`] to be passed in, which details how to draw the graph.
/// - Expects `graph_data`, which represents *what* data to draw, and various details like style and optional legends.
pub fn draw_time_graph<B: Backend>(
&self, f: &mut Frame<'_, B>, draw_loc: Rect, graph_data: &[GraphData<'_>],
) {
let x_axis = self.generate_x_axis();
let y_axis = self.generate_y_axis();
// This is some ugly manual loop unswitching. Maybe unnecessary.
let data = if self.use_dot {
graph_data
.iter()
.map(|data| create_dataset(data, Marker::Dot))
.collect()
} else {
graph_data
.iter()
.map(|data| create_dataset(data, Marker::Braille))
.collect()
};
f.render_widget(
TimeChart::new(data)
.block(
Block::default()
.title(self.generate_title(draw_loc))
.borders(Borders::ALL)
.border_style(self.border_style),
)
.x_axis(x_axis)
.y_axis(y_axis)
.hidden_legend_constraints(
self.legend_constraints
.unwrap_or(super::DEFAULT_LEGEND_CONSTRAINTS),
),
draw_loc,
)
}
}
/// Creates a new [`Dataset`].
fn create_dataset<'a>(data: &'a GraphData<'a>, marker: Marker) -> Dataset<'a> {
let GraphData {
points,
style,
name,
} = data;
let dataset = Dataset::default()
.style(*style)
.data(points)
.graph_type(GraphType::Line)
.marker(marker);
if let Some(name) = name {
dataset.name(name.as_ref())
} else {
dataset
}
}
#[cfg(test)]
mod test {
use std::borrow::Cow;
use tui::{
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))
])
);
}
}

View File

@ -1,5 +1,10 @@
use tui::layout::Rect;
use crate::app; use crate::app;
use std::cmp::{max, min}; use std::{
cmp::{max, min},
time::Instant,
};
/// Return a (hard)-width vector for column widths. /// Return a (hard)-width vector for column widths.
/// ///
@ -186,8 +191,7 @@ pub fn get_start_position(
} }
} }
/// Calculate how many bars are to be /// Calculate how many bars are to be drawn within basic mode's components.
/// drawn within basic mode's components.
pub fn calculate_basic_use_bars(use_percentage: f64, num_bars_available: usize) -> usize { pub fn calculate_basic_use_bars(use_percentage: f64, num_bars_available: usize) -> usize {
std::cmp::min( std::cmp::min(
(num_bars_available as f64 * use_percentage / 100.0).round() as usize, (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. /// Determine whether a graph x-label should be hidden.
/// It is expected point_one is "further left" compared to point_two. pub fn should_hide_x_label(
/// A point is two floats, in (x, y) form. x is time, y is value. always_hide_time: bool, autohide_time: bool, timer: &mut Option<Instant>, draw_loc: Rect,
pub fn interpolate_points(point_one: &(f64, f64), point_two: &(f64, f64), time: f64) -> f64 { ) -> bool {
let delta_x = point_two.0 - point_one.0; use crate::constants::*;
let delta_y = point_two.1 - point_one.1;
let slope = delta_y / delta_x;
(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)] #[cfg(test)]
mod test { mod test {
use super::*; 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] #[test]
fn test_zero_width() { fn test_zero_width() {
assert_eq!( assert_eq!(
@ -222,7 +419,6 @@ mod test {
true true
), ),
vec![], vec![],
"vector should be empty"
); );
} }
@ -238,7 +434,6 @@ mod test {
true true
), ),
vec![], vec![],
"vector should be empty"
); );
} }
@ -254,7 +449,6 @@ mod test {
true true
), ),
vec![2, 2, 7], vec![2, 2, 7],
"vector should not be empty"
); );
} }
} }

View File

@ -1,36 +1,32 @@
use once_cell::sync::Lazy; use std::borrow::Cow;
use unicode_segmentation::UnicodeSegmentation;
use crate::{ use crate::{
app::{layout_manager::WidgetDirection, App}, app::{layout_manager::WidgetDirection, App},
canvas::{ 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, Painter,
}, },
constants::*, constants::*,
data_conversion::ConvertedCpuData, data_conversion::ConvertedCpuData,
}; };
use concat_string::concat_string;
use tui::{ use tui::{
backend::Backend, backend::Backend,
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
symbols::Marker,
terminal::Frame, terminal::Frame,
text::Span, text::Text,
text::{Spans, Text}, widgets::{Block, Borders, Row, Table},
widgets::{Axis, Block, Borders, Chart, Dataset, Row, Table},
}; };
const CPU_LEGEND_HEADER: [&str; 2] = ["CPU", "Use%"]; const CPU_LEGEND_HEADER: [&str; 2] = ["CPU", "Use%"];
const AVG_POSITION: usize = 1; const AVG_POSITION: usize = 1;
const ALL_POSITION: usize = 0; const ALL_POSITION: usize = 0;
static CPU_LEGEND_HEADER_LENS: Lazy<Vec<u16>> = Lazy::new(|| { static CPU_LEGEND_HEADER_LENS: [usize; 2] =
CPU_LEGEND_HEADER [CPU_LEGEND_HEADER[0].len(), CPU_LEGEND_HEADER[1].len()];
.iter()
.map(|entry| entry.len() as u16)
.collect::<Vec<_>>()
});
impl Painter { impl Painter {
pub fn draw_cpu<B: Backend>( pub fn draw_cpu<B: Backend>(
@ -122,250 +118,93 @@ impl Painter {
fn draw_cpu_graph<B: Backend>( fn draw_cpu_graph<B: Backend>(
&self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64, &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) { 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 cpu_data = &app_state.canvas_data.cpu_data;
let border_style = self.get_border_style(widget_id, app_state.current_widget.widget_id);
let display_time_labels = vec![ let x_bounds = [0, cpu_widget_state.current_display_time];
Span::styled( let hide_x_labels = should_hide_x_label(
format!("{}s", cpu_widget_state.current_display_time / 1000), app_state.app_config_fields.hide_time,
self.colours.graph_style, app_state.app_config_fields.autohide_time,
), &mut cpu_widget_state.autohide_timer,
Span::styled("0s".to_string(), self.colours.graph_style), draw_loc,
];
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 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),
); );
let show_avg_cpu = app_state.app_config_fields.show_average_cpu;
if let Some(to_replace) = cpu.cpu_data.get_mut(start_pos) { let show_avg_offset = if show_avg_cpu { AVG_POSITION } else { 0 };
*to_replace = new_point; let points = {
Some((start_pos, old)) let current_scroll_position = cpu_widget_state.scroll_state.current_scroll_position;
} else { if current_scroll_position == ALL_POSITION {
None // Failed to get mutable reference. // This case ensures the other cases cannot have the position be equal to 0.
}
} 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::<Vec<_>>();
let dataset_vector: Vec<Dataset<'_>> = if current_scroll_position == ALL_POSITION {
cpu_data cpu_data
.iter() .iter()
.enumerate() .enumerate()
.rev() .rev()
.map(|(itx, cpu)| { .map(|(itx, cpu)| {
Dataset::default() let style = if show_avg_cpu && itx == AVG_POSITION {
.marker(if use_dot {
Marker::Dot
} else {
Marker::Braille
})
.style(if show_avg_cpu && itx == AVG_POSITION {
self.colours.avg_colour_style self.colours.avg_colour_style
} else if itx == ALL_POSITION { } else if itx == ALL_POSITION {
self.colours.all_colour_style self.colours.all_colour_style
} else { } else {
self.colours.cpu_colour_styles[(itx - 1 // Because of the all position let offset_position = itx - 1; // Because of the all position
- (if show_avg_cpu { self.colours.cpu_colour_styles[(offset_position - show_avg_offset)
AVG_POSITION
} else {
0
}))
% self.colours.cpu_colour_styles.len()] % self.colours.cpu_colour_styles.len()]
};
GraphData {
points: &cpu.cpu_data[..],
style,
name: None,
}
}) })
.data(&cpu.cpu_data[..]) .collect::<Vec<_>>()
.graph_type(tui::widgets::GraphType::Line)
})
.collect()
} else if let Some(cpu) = cpu_data.get(current_scroll_position) { } else if let Some(cpu) = cpu_data.get(current_scroll_position) {
vec![Dataset::default() let style = if show_avg_cpu && current_scroll_position == AVG_POSITION {
.marker(if use_dot {
Marker::Dot
} else {
Marker::Braille
})
.style(if show_avg_cpu && current_scroll_position == AVG_POSITION {
self.colours.avg_colour_style self.colours.avg_colour_style
} else { } else {
self.colours.cpu_colour_styles[(cpu_widget_state let offset_position = current_scroll_position - 1; // Because of the all position
.scroll_state self.colours.cpu_colour_styles[(offset_position - show_avg_offset)
.current_scroll_position
- 1 // Because of the all position
- (if show_avg_cpu {
AVG_POSITION
} else {
0
}))
% self.colours.cpu_colour_styles.len()] % self.colours.cpu_colour_styles.len()]
}) };
.data(&cpu.cpu_data[..])
.graph_type(tui::widgets::GraphType::Line)] vec![GraphData {
points: &cpu.cpu_data[..],
style,
name: None,
}]
} else { } else {
vec![] vec![]
}
}; };
let is_on_widget = widget_id == app_state.current_widget.widget_id; // TODO: Maybe hide load avg if too long? Or maybe the CPU part.
let border_style = if is_on_widget {
self.colours.highlighted_border_style
} else {
self.colours.border_style
};
let title = if cfg!(target_family = "unix") { let title = if cfg!(target_family = "unix") {
let load_avg = app_state.canvas_data.load_avg_data; let load_avg = app_state.canvas_data.load_avg_data;
let load_avg_str = format!( let load_avg_str = format!(
"─ {:.2} {:.2} {:.2} ", "─ {:.2} {:.2} {:.2} ",
load_avg[0], load_avg[1], load_avg[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 { concat_string!(" CPU ", load_avg_str).into()
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 { } else {
Spans::from(vec![ " CPU ".into()
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,
),
])
} else {
Spans::from(vec![Span::styled(" CPU ", self.colours.widget_title_style)])
}; };
f.render_widget( TimeGraph {
Chart::new(dataset_vector) use_dot: app_state.app_config_fields.use_dot,
.block( x_bounds,
Block::default() hide_x_labels,
.title(title) y_bounds: Y_BOUNDS,
.borders(Borders::ALL) y_labels: &Y_LABELS,
.border_style(border_style), graph_style: self.colours.graph_style,
) border_style,
.x_axis(x_axis) title,
.y_axis(y_axis), is_expanded: app_state.is_expanded,
draw_loc, title_style: self.colours.widget_title_style,
); legend_constraints: None,
// 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;
} }
} .draw_time_graph(f, draw_loc, &points);
});
} }
} }
@ -416,7 +255,7 @@ impl Painter {
&[None, None], &[None, None],
&(CPU_LEGEND_HEADER_LENS &(CPU_LEGEND_HEADER_LENS
.iter() .iter()
.map(|width| Some(*width)) .map(|width| Some(*width as u16))
.collect::<Vec<_>>()), .collect::<Vec<_>>()),
&[Some(0.5), Some(0.5)], &[Some(0.5), Some(0.5)],
&(cpu_widget_state &(cpu_widget_state

View File

@ -1,234 +1,72 @@
use std::borrow::Cow;
use crate::{ use crate::{
app::App, app::App,
canvas::{drawing_utils::interpolate_points, Painter}, canvas::{
constants::*, components::{GraphData, TimeGraph},
drawing_utils::should_hide_x_label,
Painter,
},
}; };
use tui::{ use tui::{
backend::Backend, backend::Backend,
layout::{Constraint, Rect}, layout::{Constraint, Rect},
symbols::Marker,
terminal::Frame, terminal::Frame,
text::Span,
text::Spans,
widgets::{Axis, Block, Borders, Chart, Dataset},
}; };
use unicode_segmentation::UnicodeSegmentation;
impl Painter { impl Painter {
pub fn draw_memory_graph<B: Backend>( pub fn draw_memory_graph<B: Backend>(
&self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64, &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) { 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 border_style = self.get_border_style(widget_id, app_state.current_widget.widget_id);
let swap_data: &mut [(f64, f64)] = &mut app_state.canvas_data.swap_data; let x_bounds = [0, mem_widget_state.current_display_time];
let hide_x_labels = should_hide_x_label(
let time_start = -(mem_widget_state.current_display_time as f64); app_state.app_config_fields.hide_time,
app_state.app_config_fields.autohide_time,
let display_time_labels = vec![ &mut mem_widget_state.autohide_timer,
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<Dataset<'_>> = 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))),
draw_loc, draw_loc,
); );
let points = {
// Now if you're done, reset any interpolated points! let mut points = Vec::with_capacity(2);
if let Some((index, old_value)) = interpolated_mem_point { if let Some((label_percent, label_frac)) = &app_state.canvas_data.mem_labels {
if let Some(to_replace) = mem_data.get_mut(index) { let mem_label = format!("RAM:{}{}", label_percent, label_frac);
*to_replace = old_value; points.push(GraphData {
points: &app_state.canvas_data.mem_data,
style: self.colours.ram_style,
name: Some(mem_label.into()),
});
} }
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()),
});
} }
if let Some((index, old_value)) = interpolated_swap_point { points
if let Some(to_replace) = swap_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: " 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() { if app_state.should_get_widget_bounds() {

View File

@ -1,12 +1,12 @@
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use std::cmp::max; use std::cmp::max;
use unicode_segmentation::UnicodeSegmentation;
use crate::{ use crate::{
app::{App, AxisScaling}, app::{App, AxisScaling},
canvas::{ canvas::{
drawing_utils::{get_column_widths, interpolate_points}, components::{GraphData, TimeGraph},
Painter, drawing_utils::{get_column_widths, should_hide_x_label},
Painter, Point,
}, },
constants::*, constants::*,
units::data_units::DataUnit, units::data_units::DataUnit,
@ -16,11 +16,9 @@ use crate::{
use tui::{ use tui::{
backend::Backend, backend::Backend,
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
symbols::Marker,
terminal::Frame, terminal::Frame,
text::Span, text::Text,
text::{Spans, Text}, widgets::{Block, Borders, Row, Table},
widgets::{Axis, Block, Borders, Chart, Dataset, Row, Table},
}; };
const NETWORK_HEADERS: [&str; 4] = ["RX", "TX", "Total RX", "Total TX"]; const NETWORK_HEADERS: [&str; 4] = ["RX", "TX", "Total RX", "Total TX"];
@ -68,14 +66,184 @@ impl Painter {
&self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64, &self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64,
hide_legend: bool, hide_legend: bool,
) { ) {
/// Point is of time, data if let Some(network_widget_state) = app_state.net_state.widget_states.get_mut(&widget_id) {
type Point = (f64, f64); 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 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,
);
/// Returns the max data point and time given a time. // TODO: Cache network results: Only update if:
fn get_max_entry( // - Force update (includes time interval change)
// - Old max time is off screen
// - 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,
time_start,
&app_state.app_config_fields.network_scale_type,
app_state.app_config_fields.network_use_binary_prefix,
);
let (max_range, labels) = adjust_network_data_point(
max_entry,
&app_state.app_config_fields.network_scale_type,
&app_state.app_config_fields.network_unit_type,
app_state.app_config_fields.network_use_binary_prefix,
);
let y_labels = labels.iter().map(|label| label.into()).collect::<Vec<_>>();
let y_bounds = [0.0, max_range];
let legend_constraints = if hide_legend {
(Constraint::Ratio(0, 1), Constraint::Ratio(0, 1))
} else {
(Constraint::Ratio(1, 1), Constraint::Ratio(3, 4))
};
// TODO: Add support for clicking on legend to only show that value on chart.
let points = if app_state.app_config_fields.use_old_network_legend && !hide_legend {
vec![
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![
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()),
},
]
};
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);
}
}
fn draw_network_labels<B: Backend>(
&self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64,
) {
let table_gap = if draw_loc.height < TABLE_GAP_HEIGHT_LIMIT {
0
} else {
app_state.app_config_fields.table_gap
};
let rx_display = &app_state.canvas_data.rx_display;
let tx_display = &app_state.canvas_data.tx_display;
let total_rx_display = &app_state.canvas_data.total_rx_display;
let total_tx_display = &app_state.canvas_data.total_tx_display;
// Gross but I need it to work...
let total_network = vec![vec![
Text::raw(rx_display),
Text::raw(tx_display),
Text::raw(total_rx_display),
Text::raw(total_tx_display),
]];
let mapped_network = total_network
.into_iter()
.map(|val| Row::new(val).style(self.colours.text_style));
// Calculate widths
let intrinsic_widths = get_column_widths(
draw_loc.width,
&[None, None, None, None],
&(NETWORK_HEADERS_LENS
.iter()
.map(|s| Some(*s))
.collect::<Vec<_>>()),
&[Some(0.25); 4],
&(NETWORK_HEADERS_LENS
.iter()
.map(|s| Some(*s))
.collect::<Vec<_>>()),
true,
);
// Draw
f.render_widget(
Table::new(mapped_network)
.header(
Row::new(NETWORK_HEADERS.to_vec())
.style(self.colours.table_header_style)
.bottom_margin(table_gap),
)
.block(Block::default().borders(Borders::ALL).border_style(
if app_state.current_widget.widget_id == widget_id {
self.colours.highlighted_border_style
} else {
self.colours.border_style
},
))
.style(self.colours.text_style)
.widths(
&(intrinsic_widths
.iter()
.map(|calculated_width| Constraint::Length(*calculated_width))
.collect::<Vec<_>>()),
),
draw_loc,
);
}
}
/// 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, rx: &[Point], tx: &[Point], time_start: f64, network_scale_type: &AxisScaling,
network_use_binary_prefix: bool, network_use_binary_prefix: bool,
) -> (f64, f64) { ) -> (f64, f64) {
/// Determines a "fake" max value in circumstances where we couldn't find one from the data. /// Determines a "fake" max value in circumstances where we couldn't find one from the data.
fn calculate_missing_max( fn calculate_missing_max(
network_scale_type: &AxisScaling, network_use_binary_prefix: bool, network_scale_type: &AxisScaling, network_use_binary_prefix: bool,
@ -133,10 +301,7 @@ impl Painter {
if *max_val == 0.0 { if *max_val == 0.0 {
( (
time_start, time_start,
calculate_missing_max( calculate_missing_max(network_scale_type, network_use_binary_prefix),
network_scale_type,
network_use_binary_prefix,
),
) )
} else { } else {
(*best_time, *max_val) (*best_time, *max_val)
@ -157,10 +322,7 @@ impl Painter {
if *max_val == 0.0 { if *max_val == 0.0 {
( (
time_start, time_start,
calculate_missing_max( calculate_missing_max(network_scale_type, network_use_binary_prefix),
network_scale_type,
network_use_binary_prefix,
),
) )
} else { } else {
(*best_time, *max_val) (*best_time, *max_val)
@ -182,10 +344,7 @@ impl Painter {
if *max_val == 0.0 { if *max_val == 0.0 {
( (
*best_time, *best_time,
calculate_missing_max( calculate_missing_max(network_scale_type, network_use_binary_prefix),
network_scale_type,
network_use_binary_prefix,
),
) )
} else { } else {
(*best_time, *max_val) (*best_time, *max_val)
@ -198,13 +357,13 @@ impl Painter {
} }
} }
} }
} }
/// Returns the required max data point and labels. /// Returns the required max data point and labels.
fn adjust_network_data_point( fn adjust_network_data_point(
max_entry: f64, network_scale_type: &AxisScaling, network_unit_type: &DataUnit, max_entry: f64, network_scale_type: &AxisScaling, network_unit_type: &DataUnit,
network_use_binary_prefix: bool, network_use_binary_prefix: bool,
) -> (f64, Vec<String>) { ) -> (f64, Vec<String>) {
// So, we're going with an approach like this for linear data: // 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. // - 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 // We don't want to drown out some data if the ranges are too far though! Nor do we want to filter
@ -401,355 +560,4 @@ impl Painter {
} }
} }
} }
}
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 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.
};
// TODO: Cache network results: Only update if:
// - Force update (includes time interval change)
// - Old max time is off screen
// - 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,
time_start,
&app_state.app_config_fields.network_scale_type,
app_state.app_config_fields.network_use_binary_prefix,
);
let (max_range, labels) = adjust_network_data_point(
max_entry,
&app_state.app_config_fields.network_scale_type,
&app_state.app_config_fields.network_unit_type,
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::<Vec<_>>();
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 legend_constraints = if hide_legend {
(Constraint::Ratio(0, 1), Constraint::Ratio(0, 1))
} else {
(Constraint::Ratio(1, 1), Constraint::Ratio(3, 4))
};
// 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 {
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),
]
} 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),
]
};
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;
}
}
}
}
fn draw_network_labels<B: Backend>(
&self, f: &mut Frame<'_, B>, app_state: &mut App, draw_loc: Rect, widget_id: u64,
) {
let table_gap = if draw_loc.height < TABLE_GAP_HEIGHT_LIMIT {
0
} else {
app_state.app_config_fields.table_gap
};
let rx_display = &app_state.canvas_data.rx_display;
let tx_display = &app_state.canvas_data.tx_display;
let total_rx_display = &app_state.canvas_data.total_rx_display;
let total_tx_display = &app_state.canvas_data.total_tx_display;
// Gross but I need it to work...
let total_network = vec![vec![
Text::raw(rx_display),
Text::raw(tx_display),
Text::raw(total_rx_display),
Text::raw(total_tx_display),
]];
let mapped_network = total_network
.into_iter()
.map(|val| Row::new(val).style(self.colours.text_style));
// Calculate widths
let intrinsic_widths = get_column_widths(
draw_loc.width,
&[None, None, None, None],
&(NETWORK_HEADERS_LENS
.iter()
.map(|s| Some(*s))
.collect::<Vec<_>>()),
&[Some(0.25); 4],
&(NETWORK_HEADERS_LENS
.iter()
.map(|s| Some(*s))
.collect::<Vec<_>>()),
true,
);
// Draw
f.render_widget(
Table::new(mapped_network)
.header(
Row::new(NETWORK_HEADERS.to_vec())
.style(self.colours.table_header_style)
.bottom_margin(table_gap),
)
.block(Block::default().borders(Borders::ALL).border_style(
if app_state.current_widget.widget_id == widget_id {
self.colours.highlighted_border_style
} else {
self.colours.border_style
},
))
.style(self.colours.text_style)
.widths(
&(intrinsic_widths
.iter()
.map(|calculated_width| Constraint::Length(*calculated_width))
.collect::<Vec<_>>()),
),
draw_loc,
);
}
} }