refactor: port over graph widgets

Things working as of now:
- Actually drawing
- Interpolation
- Styling
This commit is contained in:
ClementTsang 2021-08-28 04:15:36 -04:00
parent b72e76aa71
commit 2bff04d8a4
21 changed files with 2162 additions and 321 deletions

7
Cargo.lock generated
View File

@ -246,6 +246,7 @@ dependencies = [
"dirs",
"enum_dispatch",
"fern",
"float-ord",
"futures",
"futures-timer",
"fxhash",
@ -584,6 +585,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "float-ord"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
[[package]]
name = "futures"
version = "0.3.14"

View File

@ -44,6 +44,7 @@ clap = "2.33"
cfg-if = "1.0"
dirs = "3.0.2"
enum_dispatch = "0.3.7"
float-ord = "0.3.2"
futures = "0.3.14"
futures-timer = "3.0.2"
fxhash = "0.2.1"

View File

@ -19,7 +19,7 @@ use indextree::{Arena, NodeId};
use unicode_segmentation::GraphemeCursor;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use data_farmer::*;
pub use data_farmer::*;
use data_harvester::{processes, temperature};
pub use filter::*;
use layout_manager::*;
@ -28,9 +28,7 @@ pub use widgets::*;
use crate::{
canvas,
constants::{self, MAX_SIGNAL},
data_conversion::*,
units::data_units::DataUnit,
update_final_process_list,
utils::error::{BottomError, Result},
BottomEvent, Pid,
};
@ -367,7 +365,10 @@ impl AppState {
self.data_collection.eat_data(new_data);
if !self.is_frozen {
self.convert_data();
let data_collection = &self.data_collection;
self.widget_lookup_map
.iter_mut()
.for_each(|(_id, widget)| widget.update_data(data_collection));
EventResult::Redraw
} else {
@ -395,89 +396,6 @@ impl AppState {
}
}
fn convert_data(&mut self) {
// TODO: Probably refactor this.
// Network
if self.used_widgets.use_net {
let network_data = convert_network_data_points(
&self.data_collection,
false,
self.app_config_fields.use_basic_mode
|| self.app_config_fields.use_old_network_legend,
&self.app_config_fields.network_scale_type,
&self.app_config_fields.network_unit_type,
self.app_config_fields.network_use_binary_prefix,
);
self.canvas_data.network_data_rx = network_data.rx;
self.canvas_data.network_data_tx = network_data.tx;
self.canvas_data.rx_display = network_data.rx_display;
self.canvas_data.tx_display = network_data.tx_display;
if let Some(total_rx_display) = network_data.total_rx_display {
self.canvas_data.total_rx_display = total_rx_display;
}
if let Some(total_tx_display) = network_data.total_tx_display {
self.canvas_data.total_tx_display = total_tx_display;
}
}
// Disk
if self.used_widgets.use_disk {
self.canvas_data.disk_data = convert_disk_row(&self.data_collection);
}
// Temperatures
if self.used_widgets.use_temp {
self.canvas_data.temp_sensor_data = convert_temp_row(&self);
}
// Memory
if self.used_widgets.use_mem {
self.canvas_data.mem_data = convert_mem_data_points(&self.data_collection, false);
self.canvas_data.swap_data = convert_swap_data_points(&self.data_collection, false);
let (memory_labels, swap_labels) = convert_mem_labels(&self.data_collection);
self.canvas_data.mem_labels = memory_labels;
self.canvas_data.swap_labels = swap_labels;
}
if self.used_widgets.use_cpu {
// CPU
convert_cpu_data_points(&self.data_collection, &mut self.canvas_data.cpu_data, false);
self.canvas_data.load_avg_data = self.data_collection.load_avg_harvest;
}
// Processes
if self.used_widgets.use_proc {
self.update_all_process_lists();
}
// Battery
if self.used_widgets.use_battery {
self.canvas_data.battery_data = convert_battery_harvest(&self.data_collection);
}
}
#[allow(clippy::needless_collect)]
fn update_all_process_lists(&mut self) {
// TODO: Probably refactor this.
// According to clippy, I can avoid a collect... but if I follow it,
// I end up conflicting with the borrow checker since app is used within the closure... hm.
if !self.is_frozen {
let widget_ids = self
.proc_state
.widget_states
.keys()
.cloned()
.collect::<Vec<_>>();
widget_ids.into_iter().for_each(|widget_id| {
update_final_process_list(self, widget_id);
});
}
}
pub fn on_esc(&mut self) {
self.reset_multi_tap_keys();
if self.is_in_dialog() {

View File

@ -1,8 +1,5 @@
use crate::{
app::{
sort_text_table::SortableColumn, DiskTable, MemGraph, NetGraph, OldNetGraph,
ProcessManager, TempTable,
},
app::{DiskTable, MemGraph, NetGraph, OldNetGraph, ProcessManager, TempTable},
error::{BottomError, Result},
options::layout_options::{Row, RowChildren},
};
@ -16,7 +13,7 @@ use crate::app::widgets::Widget;
use crate::constants::DEFAULT_WIDGET_ID;
use super::{
event::SelectionAction, CpuGraph, SortableTextTable, TimeGraph, TmpBottomWidget, UsedWidgets,
event::SelectionAction, AppConfigFields, CpuGraph, TimeGraph, TmpBottomWidget, UsedWidgets,
};
/// Represents a more usable representation of the layout, derived from the
@ -1051,44 +1048,43 @@ pub struct LayoutCreationOutput {
// FIXME: This is currently jury-rigged "glue" just to work with the existing config system! We are NOT keeping it like this, it's too awful to keep like this!
pub fn create_layout_tree(
rows: &[Row], process_defaults: crate::options::ProcessDefaults,
app_config_fields: &super::AppConfigFields,
app_config_fields: &AppConfigFields,
) -> Result<LayoutCreationOutput> {
fn add_widget_to_map(
widget_lookup_map: &mut FxHashMap<NodeId, TmpBottomWidget>, widget_type: BottomWidgetType,
widget_id: NodeId, process_defaults: &crate::options::ProcessDefaults,
app_config_fields: &super::AppConfigFields,
app_config_fields: &AppConfigFields,
) -> Result<()> {
match widget_type {
BottomWidgetType::Cpu => {
let graph = TimeGraph::from_config(app_config_fields);
let legend = SortableTextTable::new(vec![
SortableColumn::new_flex("CPU".into(), None, false, 0.5),
SortableColumn::new_flex("Use%".into(), None, false, 0.5),
]);
let legend_position = super::CpuGraphLegendPosition::Right;
widget_lookup_map.insert(
widget_id,
CpuGraph::new(graph, legend, legend_position).into(),
);
widget_lookup_map
.insert(widget_id, CpuGraph::from_config(app_config_fields).into());
}
BottomWidgetType::Mem => {
let graph = TimeGraph::from_config(app_config_fields);
widget_lookup_map.insert(widget_id, MemGraph::new(graph).into());
}
BottomWidgetType::Net => {
let graph = TimeGraph::from_config(app_config_fields);
if app_config_fields.use_old_network_legend {
widget_lookup_map.insert(widget_id, OldNetGraph::new(graph).into());
widget_lookup_map.insert(
widget_id,
OldNetGraph::from_config(app_config_fields).into(),
);
} else {
widget_lookup_map.insert(widget_id, NetGraph::new(graph).into());
widget_lookup_map
.insert(widget_id, NetGraph::from_config(app_config_fields).into());
}
}
BottomWidgetType::Proc => {
widget_lookup_map.insert(widget_id, ProcessManager::new(process_defaults).into());
}
BottomWidgetType::Temp => {
widget_lookup_map.insert(widget_id, TempTable::default().into());
widget_lookup_map.insert(
widget_id,
TempTable::default()
.set_temp_type(app_config_fields.temperature_type.clone())
.into(),
);
}
BottomWidgetType::Disk => {
widget_lookup_map.insert(widget_id, DiskTable::default().into());

View File

@ -9,10 +9,12 @@ use crate::{
event::{EventResult, SelectionAction},
layout_manager::BottomWidgetType,
},
canvas::{DisplayableData, Painter},
canvas::Painter,
constants,
};
mod tui_widgets;
pub mod base;
pub use base::*;
@ -37,6 +39,8 @@ pub use self::battery::*;
pub mod temp;
pub use temp::*;
use super::data_farmer::DataCollection;
/// A trait for things that are drawn with state.
#[enum_dispatch]
#[allow(unused_variables)]
@ -75,9 +79,6 @@ pub trait Component {
#[enum_dispatch]
#[allow(unused_variables)]
pub trait Widget {
/// Updates a [`Widget`] given some data. Defaults to doing nothing.
fn update(&mut self) {}
/// Handles what to do when trying to respond to a widget selection movement to the left.
/// Defaults to just moving to the next-possible widget in that direction.
fn handle_widget_selection_left(&mut self) -> SelectionAction {
@ -107,12 +108,13 @@ pub trait Widget {
/// Draws a [`Widget`]. Defaults to doing nothing.
fn draw<B: Backend>(
&mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, data: &DisplayableData,
selected: bool,
&mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, selected: bool,
) {
// TODO: Remove the default implementation in the future!
// TODO: Do another pass on ALL of the draw code - currently it's just glue, it should eventually be done properly!
}
/// How a [`Widget`] updates its internal displayed data. Defaults to doing nothing.
fn update_data(&mut self, data_collection: &DataCollection) {}
}
/// The "main" widgets that are used by bottom to display information!

View File

@ -61,7 +61,7 @@ impl Scrollable {
}
/// Returns the currently selected index of the [`Scrollable`].
pub fn index(&self) -> usize {
pub fn current_index(&self) -> usize {
self.current_index
}
@ -195,8 +195,8 @@ impl Scrollable {
self.num_items
}
pub fn tui_state(&self) -> TableState {
self.tui_state.clone()
pub fn tui_state(&mut self) -> &mut TableState {
&mut self.tui_state
}
}

View File

@ -2,13 +2,17 @@ use std::borrow::Cow;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use tui::{
backend::Backend,
layout::Rect,
widgets::{Table, TableState},
widgets::{Block, Table, TableState},
};
use crate::app::{event::EventResult, Component, TextTable};
use crate::{
app::{event::EventResult, Component, TextTable},
canvas::Painter,
};
use super::text_table::{DesiredColumnWidth, SimpleColumn, TableColumn};
use super::text_table::{DesiredColumnWidth, SimpleColumn, TableColumn, TextTableData};
fn get_shortcut_name(e: &KeyEvent) -> String {
let modifier = if e.modifiers.is_empty() {
@ -182,6 +186,10 @@ impl SortableTextTable {
self
}
pub fn current_index(&self) -> usize {
self.table.current_index()
}
fn set_sort_index(&mut self, new_index: usize) {
if new_index == self.sort_index {
if let Some(column) = self.table.columns.get_mut(self.sort_index) {
@ -218,6 +226,18 @@ impl SortableTextTable {
}
}
/// Draws a [`Table`] given the [`TextTable`] and the given data.
///
/// Note if the number of columns don't match in the [`TextTable`] and data,
/// it will only create as many columns as it can grab data from both sources from.
pub fn draw_tui_table<B: Backend>(
&mut self, painter: &Painter, f: &mut tui::Frame<'_, B>, data: &TextTableData,
block: Block<'_>, block_area: Rect, show_selected_entry: bool,
) {
self.table
.draw_tui_table(painter, f, data, block, block_area, show_selected_entry);
}
/// Creates a [`Table`] representing the sort list.
pub fn create_sort_list(&mut self) -> (Table<'_>, TableState) {
todo!()

View File

@ -5,9 +5,12 @@ use std::{
use crossterm::event::{KeyEvent, MouseEvent};
use tui::{
backend::Backend,
layout::{Constraint, Rect},
style::Style,
text::Text,
widgets::{Table, TableState},
widgets::{Block, Table},
Frame,
};
use unicode_segmentation::UnicodeSegmentation;
@ -36,6 +39,8 @@ pub trait TableColumn {
fn set_x_bounds(&mut self, x_bounds: Option<(u16, u16)>);
}
pub type TextTableData = Vec<Vec<(Cow<'static, str>, Option<Cow<'static, str>>, Option<Style>)>>;
/// A [`SimpleColumn`] represents some column in a [`TextTable`].
#[derive(Debug)]
pub struct SimpleColumn {
@ -128,6 +133,9 @@ where
/// Whether we draw columns from left-to-right.
pub left_to_right: bool,
/// Whether to enable selection.
pub selectable: bool,
}
impl<C> TextTable<C>
@ -142,6 +150,7 @@ where
show_gap: true,
bounds: Rect::default(),
left_to_right: true,
selectable: true,
}
}
@ -155,6 +164,11 @@ where
self
}
pub fn unselectable(mut self) -> Self {
self.selectable = false;
self
}
pub fn displayed_column_names(&self) -> Vec<Cow<'static, str>> {
self.columns
.iter()
@ -172,8 +186,12 @@ where
}
}
pub fn current_index(&self) -> usize {
self.scrollable.current_index()
}
pub fn get_desired_column_widths(
columns: &[C], data: &[Vec<(Cow<'static, str>, Option<Cow<'static, str>>)>],
columns: &[C], data: &TextTableData,
) -> Vec<DesiredColumnWidth> {
columns
.iter()
@ -183,22 +201,22 @@ where
let max_len = data
.iter()
.filter_map(|c| c.get(column_index))
.max_by(|(x, short_x), (y, short_y)| {
let x = if let Some(short_x) = short_x {
short_x
.max_by(|(a, short_a, _a_style), (b, short_b, _b_style)| {
let a_len = if let Some(short_a) = short_a {
short_a.len()
} else {
x
a.len()
};
let y = if let Some(short_y) = short_y {
short_y
let b_len = if let Some(short_b) = short_b {
short_b.len()
} else {
y
b.len()
};
x.len().cmp(&y.len())
a_len.cmp(&b_len)
})
.map(|(s, _)| s.len())
.map(|(longest_data_str, _, _)| longest_data_str.len())
.unwrap_or(0) as u16;
DesiredColumnWidth::Hard(max(max_len, *width))
@ -211,9 +229,7 @@ where
.collect::<Vec<_>>()
}
fn get_cache(
&mut self, area: Rect, data: &[Vec<(Cow<'static, str>, Option<Cow<'static, str>>)>],
) -> Vec<u16> {
fn get_cache(&mut self, area: Rect, data: &TextTableData) -> Vec<u16> {
fn calculate_column_widths(
left_to_right: bool, mut desired_widths: Vec<DesiredColumnWidth>, total_width: u16,
) -> Vec<u16> {
@ -326,37 +342,37 @@ where
}
}
/// Creates a [`Table`] given the [`TextTable`] and the given data, along with its
/// widths (because for some reason a [`Table`] only borrows the constraints...?)
/// and [`TableState`] (so we know which row is selected).
/// Draws a [`Table`] given the [`TextTable`] and the given data.
///
/// Note if the number of columns don't match in the [`TextTable`] and data,
/// it will only create as many columns as it can grab data from both sources from.
pub fn create_draw_table(
&mut self, painter: &Painter, data: &[Vec<(Cow<'static, str>, Option<Cow<'static, str>>)>],
area: Rect,
) -> (Table<'_>, Vec<Constraint>, TableState) {
pub fn draw_tui_table<B: Backend>(
&mut self, painter: &Painter, f: &mut Frame<'_, B>, data: &TextTableData, block: Block<'_>,
block_area: Rect, show_selected_entry: bool,
) {
use tui::widgets::Row;
let table_gap = if !self.show_gap || area.height < TABLE_GAP_HEIGHT_LIMIT {
let inner_area = block.inner(block_area);
let table_gap = if !self.show_gap || inner_area.height < TABLE_GAP_HEIGHT_LIMIT {
0
} else {
1
};
self.update_num_items(data.len());
self.set_bounds(area);
self.set_bounds(inner_area);
let table_extras = 1 + table_gap;
let scrollable_height = area.height.saturating_sub(table_extras);
let scrollable_height = inner_area.height.saturating_sub(table_extras);
self.scrollable.set_bounds(Rect::new(
area.x,
area.y + table_extras,
area.width,
inner_area.x,
inner_area.y + table_extras,
inner_area.width,
scrollable_height,
));
// Calculate widths first, since we need them later.
let calculated_widths = self.get_cache(area, data);
let calculated_widths = self.get_cache(inner_area, data);
let widths = calculated_widths
.iter()
.map(|column| Constraint::Length(*column))
@ -373,25 +389,28 @@ where
&data[start..end]
};
let rows = data_slice.iter().map(|row| {
Row::new(
row.iter()
.zip(&calculated_widths)
.map(|((text, shrunk_text), width)| {
let width = *width as usize;
let graphemes = UnicodeSegmentation::graphemes(text.as_ref(), true)
.collect::<Vec<&str>>();
let grapheme_width = graphemes.len();
if width < grapheme_width && width > 1 {
if let Some(shrunk_text) = shrunk_text {
Text::raw(shrunk_text.clone())
} else {
Text::raw(format!("{}", graphemes[..(width - 1)].concat()))
}
Row::new(row.iter().zip(&calculated_widths).map(
|((text, shrunk_text, opt_style), width)| {
let text_style = opt_style.unwrap_or(painter.colours.text_style);
let width = *width as usize;
let graphemes =
UnicodeSegmentation::graphemes(text.as_ref(), true).collect::<Vec<&str>>();
let grapheme_width = graphemes.len();
if width < grapheme_width && width > 1 {
if let Some(shrunk_text) = shrunk_text {
Text::styled(shrunk_text.clone(), text_style)
} else {
Text::raw(text.to_owned())
Text::styled(
format!("{}", graphemes[..(width - 1)].concat()),
text_style,
)
}
}),
)
} else {
Text::styled(text.to_owned(), text_style)
}
},
))
});
// Now build up our headers...
@ -399,21 +418,24 @@ where
.style(painter.colours.table_header_style)
.bottom_margin(table_gap);
// And return tui-rs's [`TableState`].
let tui_state = self.scrollable.tui_state();
let table = Table::new(rows)
.header(header)
.style(painter.colours.text_style)
.highlight_style(if show_selected_entry {
painter.colours.currently_selected_text_style
} else {
painter.colours.text_style
});
(
Table::new(rows)
.header(header)
.style(painter.colours.text_style),
widths,
tui_state,
)
}
/// Creates a [`Table`] representing the sort list.
pub fn create_sort_list(&mut self) -> (Table<'_>, TableState) {
todo!()
if self.selectable {
f.render_stateful_widget(
table.block(block).widths(&widths),
block_area,
self.scrollable.tui_state(),
);
} else {
f.render_widget(table.block(block).widths(&widths), block_area);
}
}
}
@ -422,11 +444,19 @@ where
C: TableColumn,
{
fn handle_key_event(&mut self, event: KeyEvent) -> EventResult {
self.scrollable.handle_key_event(event)
if self.selectable {
self.scrollable.handle_key_event(event)
} else {
EventResult::NoRedraw
}
}
fn handle_mouse_event(&mut self, event: MouseEvent) -> EventResult {
self.scrollable.handle_mouse_event(event)
if self.selectable {
self.scrollable.handle_mouse_event(event)
} else {
EventResult::NoRedraw
}
}
fn bounds(&self) -> Rect {

View File

@ -1,10 +1,28 @@
use std::time::{Duration, Instant};
use std::{
borrow::Cow,
time::{Duration, Instant},
};
use crossterm::event::{KeyEvent, KeyModifiers, MouseEvent};
use tui::layout::Rect;
use crossterm::event::{KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use tui::{
backend::Backend,
layout::{Constraint, Rect},
style::Style,
symbols::Marker,
text::Span,
widgets::{Block, GraphType},
};
use crate::{
app::{event::EventResult, AppConfigFields, Component},
app::{
event::EventResult,
widgets::tui_widgets::{
custom_legend_chart::{Axis, Dataset},
TimeChart,
},
AppConfigFields, Component,
},
canvas::Painter,
constants::{AUTOHIDE_TIMEOUT_MILLISECONDS, STALE_MAX_MILLISECONDS, STALE_MIN_MILLISECONDS},
};
@ -24,6 +42,7 @@ pub enum AutohideTimer {
},
}
// TODO: [AUTOHIDE] Not a fan of how this is done, as this should really "trigger" a draw when it's done.
impl AutohideTimer {
fn start_display_timer(&mut self) {
match self {
@ -57,6 +76,27 @@ impl AutohideTimer {
},
}
}
pub fn is_showing(&mut self) -> bool {
self.update_display_timer();
match self {
AutohideTimer::AlwaysShow => true,
AutohideTimer::AlwaysHide => false,
AutohideTimer::Enabled {
state,
show_duration: _,
} => match state {
AutohideTimerState::Hidden => false,
AutohideTimerState::Running(_) => true,
},
}
}
}
pub struct TimeGraphData<'d> {
pub data: &'d [(f64, f64)],
pub label: Option<Cow<'static, str>>,
pub style: Style,
}
/// A graph widget with controllable time ranges along the x-axis.
@ -71,13 +111,15 @@ pub struct TimeGraph {
time_interval: u64,
bounds: Rect,
use_dot: bool,
}
impl TimeGraph {
/// Creates a new [`TimeGraph`]. All time values are in milliseconds.
pub fn new(
start_value: u64, autohide_timer: AutohideTimer, min_duration: u64, max_duration: u64,
time_interval: u64,
time_interval: u64, use_dot: bool,
) -> Self {
Self {
current_display_time: start_value,
@ -87,6 +129,7 @@ impl TimeGraph {
max_duration,
time_interval,
bounds: Rect::default(),
use_dot,
}
}
@ -107,6 +150,7 @@ impl TimeGraph {
STALE_MIN_MILLISECONDS,
STALE_MAX_MILLISECONDS,
app_config_fields.time_interval,
app_config_fields.use_dot,
)
}
@ -165,6 +209,89 @@ impl TimeGraph {
EventResult::Redraw
}
}
fn get_x_axis_labels(&self, painter: &Painter) -> Vec<Span<'_>> {
vec![
Span::styled(
format!("{}s", self.current_display_time / 1000),
painter.colours.graph_style,
),
Span::styled("0s", painter.colours.graph_style),
]
}
pub fn get_current_display_time(&self) -> u64 {
self.current_display_time
}
/// Creates a [`Chart`].
///
/// The `reverse_order` parameter is mostly used for cases where you want the first entry to be drawn on
/// top - note that this will also reverse the naturally generated legend, if shown!
pub fn draw_tui_chart<B: Backend>(
&mut self, painter: &Painter, f: &mut tui::Frame<'_, B>, data: &'_ [TimeGraphData<'_>],
y_bound_labels: &[Cow<'static, str>], y_bounds: [f64; 2], reverse_order: bool,
block: Block<'_>, block_area: Rect,
) {
let inner_area = block.inner(block_area);
self.set_bounds(inner_area);
let time_start = -(self.current_display_time as f64);
let x_axis = {
let x_axis = Axis::default()
.bounds([time_start, 0.0])
.style(painter.colours.graph_style);
if self.autohide_timer.is_showing() {
x_axis.labels(self.get_x_axis_labels(painter))
} else {
x_axis
}
};
let y_axis = Axis::default()
.bounds(y_bounds)
.style(painter.colours.graph_style)
.labels(
y_bound_labels
.into_iter()
.map(|label| Span::styled(label.clone(), painter.colours.graph_style))
.collect(),
);
let mut datasets: Vec<Dataset<'_>> = data
.iter()
.map(|time_graph_data| {
let mut dataset = Dataset::default()
.data(time_graph_data.data)
.style(time_graph_data.style)
.marker(if self.use_dot {
Marker::Dot
} else {
Marker::Braille
})
.graph_type(GraphType::Line);
if let Some(label) = &time_graph_data.label {
dataset = dataset.name(label.clone());
}
dataset
})
.collect();
if reverse_order {
datasets.reverse();
}
let chart = TimeChart::new(datasets)
.x_axis(x_axis)
.y_axis(y_axis)
.style(painter.colours.graph_style)
.legend_style(painter.colours.graph_style)
.hidden_legend_constraints((Constraint::Ratio(3, 4), Constraint::Ratio(3, 4)));
f.render_widget(chart.block(block), block_area);
}
}
impl Component for TimeGraph {
@ -183,8 +310,8 @@ impl Component for TimeGraph {
fn handle_mouse_event(&mut self, event: MouseEvent) -> EventResult {
match event.kind {
crossterm::event::MouseEventKind::ScrollDown => self.zoom_out(),
crossterm::event::MouseEventKind::ScrollUp => self.zoom_in(),
MouseEventKind::ScrollDown => self.zoom_out(),
MouseEventKind::ScrollUp => self.zoom_in(),
_ => EventResult::NoRedraw,
}
}

View File

@ -2,6 +2,11 @@ use std::collections::HashMap;
use tui::layout::Rect;
use crate::{
app::data_farmer::DataCollection,
data_conversion::{convert_battery_harvest, ConvertedBatteryData},
};
use super::{Component, Widget};
#[derive(Default)]
@ -36,6 +41,7 @@ pub struct BatteryTable {
bounds: Rect,
selected_index: usize,
batteries: Vec<String>,
battery_data: Vec<ConvertedBatteryData>,
}
impl BatteryTable {
@ -70,4 +76,8 @@ impl Widget for BatteryTable {
fn get_pretty_name(&self) -> &'static str {
"Battery"
}
fn update_data(&mut self, data_collection: &DataCollection) {
self.battery_data = convert_battery_harvest(data_collection);
}
}

View File

@ -1,9 +1,20 @@
use std::{collections::HashMap, time::Instant};
use std::{borrow::Cow, collections::HashMap, time::Instant};
use crossterm::event::{KeyEvent, MouseEvent};
use tui::layout::Rect;
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
widgets::{Block, Borders},
};
use crate::app::event::EventResult;
use crate::{
app::{
event::EventResult, sort_text_table::SortableColumn, time_graph::TimeGraphData,
AppConfigFields, DataCollection,
},
canvas::Painter,
data_conversion::{convert_cpu_data_points, ConvertedCpuData},
};
use super::{
AppScrollWidgetState, CanvasTableWidthState, Component, SortableTextTable, TimeGraph, Widget,
@ -70,23 +81,40 @@ pub enum CpuGraphLegendPosition {
pub struct CpuGraph {
graph: TimeGraph,
legend: SortableTextTable,
pub legend_position: CpuGraphLegendPosition,
legend_position: CpuGraphLegendPosition,
showing_avg: bool,
bounds: Rect,
selected: CpuGraphSelection,
display_data: Vec<ConvertedCpuData>,
load_avg_data: [f32; 3],
}
impl CpuGraph {
/// Creates a new [`CpuGraph`].
pub fn new(
graph: TimeGraph, legend: SortableTextTable, legend_position: CpuGraphLegendPosition,
) -> Self {
/// Creates a new [`CpuGraph`] from a config.
pub fn from_config(app_config_fields: &AppConfigFields) -> Self {
let graph = TimeGraph::from_config(app_config_fields);
let legend = SortableTextTable::new(vec![
SortableColumn::new_flex("CPU".into(), None, false, 0.5),
SortableColumn::new_flex("Use%".into(), None, false, 0.5),
]);
let legend_position = if app_config_fields.left_legend {
CpuGraphLegendPosition::Left
} else {
CpuGraphLegendPosition::Right
};
let showing_avg = app_config_fields.show_average_cpu;
Self {
graph,
legend,
legend_position,
showing_avg,
bounds: Rect::default(),
selected: CpuGraphSelection::None,
display_data: Default::default(),
load_avg_data: [0.0; 3],
}
}
}
@ -102,11 +130,21 @@ impl Component for CpuGraph {
fn handle_mouse_event(&mut self, event: MouseEvent) -> EventResult {
if self.graph.does_intersect_mouse(&event) {
self.selected = CpuGraphSelection::Graph;
self.graph.handle_mouse_event(event)
if let CpuGraphSelection::Graph = self.selected {
self.graph.handle_mouse_event(event)
} else {
self.selected = CpuGraphSelection::Graph;
self.graph.handle_mouse_event(event);
EventResult::Redraw
}
} else if self.legend.does_intersect_mouse(&event) {
self.selected = CpuGraphSelection::Legend;
self.legend.handle_mouse_event(event)
if let CpuGraphSelection::Legend = self.selected {
self.legend.handle_mouse_event(event)
} else {
self.selected = CpuGraphSelection::Legend;
self.legend.handle_mouse_event(event);
EventResult::Redraw
}
} else {
EventResult::NoRedraw
}
@ -125,4 +163,145 @@ impl Widget for CpuGraph {
fn get_pretty_name(&self) -> &'static str {
"CPU"
}
fn draw<B: Backend>(
&mut self, painter: &Painter, f: &mut tui::Frame<'_, B>, area: Rect, selected: bool,
) {
let constraints = match self.legend_position {
CpuGraphLegendPosition::Left => {
[Constraint::Percentage(15), Constraint::Percentage(85)]
}
CpuGraphLegendPosition::Right => {
[Constraint::Percentage(85), Constraint::Percentage(15)]
}
};
let split_area = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(area);
const Y_BOUNDS: [f64; 2] = [0.0, 100.5];
let y_bound_labels: [Cow<'static, str>; 2] = ["0%".into(), "100%".into()];
let current_index = self.legend.current_index();
let sliced_cpu_data = if current_index == 0 {
&self.display_data[..]
} else {
&self.display_data[current_index..current_index + 1]
};
let cpu_data = sliced_cpu_data
.iter()
.enumerate()
.map(|(cpu_index, core_data)| TimeGraphData {
data: &core_data.cpu_data,
label: None,
style: {
let offset_cpu_index = cpu_index + current_index;
if offset_cpu_index == 0 {
painter.colours.all_colour_style
} else if self.showing_avg && offset_cpu_index == 1 {
painter.colours.avg_colour_style
} else {
let cpu_style_index = if self.showing_avg {
// No underflow should occur, as if offset_cpu_index was
// 1 and avg is showing, it's caught by the above case!
offset_cpu_index - 2
} else {
offset_cpu_index - 1
};
painter.colours.cpu_colour_styles
[cpu_style_index % painter.colours.cpu_colour_styles.len()]
}
},
})
.collect::<Vec<_>>();
let legend_data = self
.display_data
.iter()
.enumerate()
.map(|(cpu_index, core_data)| {
let style = Some(if cpu_index == 0 {
painter.colours.all_colour_style
} else if self.showing_avg && cpu_index == 1 {
painter.colours.avg_colour_style
} else {
let cpu_style_index = if self.showing_avg {
// No underflow should occur, as if cpu_index was
// 1 and avg is showing, it's caught by the above case!
cpu_index - 2
} else {
cpu_index - 1
};
painter.colours.cpu_colour_styles
[cpu_style_index % painter.colours.cpu_colour_styles.len()]
});
vec![
(
core_data.cpu_name.clone().into(),
Some(core_data.short_cpu_name.clone().into()),
style,
),
(core_data.legend_value.clone().into(), None, style),
]
})
.collect::<Vec<_>>();
let graph_block = Block::default()
.border_style(if selected {
if let CpuGraphSelection::Graph = &self.selected {
painter.colours.highlighted_border_style
} else {
painter.colours.border_style
}
} else {
painter.colours.border_style
})
.borders(Borders::ALL);
let legend_block = Block::default()
.border_style(if selected {
if let CpuGraphSelection::Legend = &self.selected {
painter.colours.highlighted_border_style
} else {
painter.colours.border_style
}
} else {
painter.colours.border_style
})
.borders(Borders::ALL);
let (graph_block_area, legend_block_area) = match self.legend_position {
CpuGraphLegendPosition::Left => (split_area[1], split_area[0]),
CpuGraphLegendPosition::Right => (split_area[0], split_area[1]),
};
self.graph.draw_tui_chart(
painter,
f,
&cpu_data,
&y_bound_labels,
Y_BOUNDS,
true,
graph_block,
graph_block_area,
);
self.legend.draw_tui_table(
painter,
f,
&legend_data,
legend_block,
legend_block_area,
true,
);
}
fn update_data(&mut self, data_collection: &DataCollection) {
convert_cpu_data_points(data_collection, &mut self.display_data, false); // TODO: Again, the "is_frozen" is probably useless
self.load_avg_data = data_collection.load_avg_harvest;
}
}

View File

@ -0,0 +1,335 @@
#[derive(Debug, Clone)]
pub struct Chart<'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,
/// Constraints used to determine whether the legend should be shown or not
hidden_legend_constraints: (Constraint, Constraint),
}
impl<'a> Chart<'a> {
pub fn new(datasets: Vec<Dataset<'a>>) -> Chart<'a> {
Chart {
block: None,
x_axis: Axis::default(),
y_axis: Axis::default(),
style: Default::default(),
datasets,
hidden_legend_constraints: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)),
}
}
pub fn block(mut self, block: Block<'a>) -> Chart<'a> {
self.block = Some(block);
self
}
pub fn style(mut self, style: Style) -> Chart<'a> {
self.style = style;
self
}
pub fn x_axis(mut self, axis: Axis<'a>) -> Chart<'a> {
self.x_axis = axis;
self
}
pub fn y_axis(mut self, axis: Axis<'a>) -> Chart<'a> {
self.y_axis = axis;
self
}
/// Set the constraints used to determine whether the legend should be shown or not.
///
/// # Examples
///
/// ```
/// # use tui::widgets::Chart;
/// # use tui::layout::Constraint;
/// let constraints = (
/// Constraint::Ratio(1, 3),
/// Constraint::Ratio(1, 4)
/// );
/// // Hide the legend when either its width is greater than 33% of the total widget width
/// // or if its height is greater than 25% of the total widget height.
/// let _chart: Chart = Chart::new(vec![])
/// .hidden_legend_constraints(constraints);
/// ```
pub fn hidden_legend_constraints(mut self, constraints: (Constraint, Constraint)) -> Chart<'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 Chart<'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| {
ctx.draw(&Points {
coords: dataset.data,
color: dataset.style.fg.unwrap_or(Color::Reset),
});
if let GraphType::Line = dataset.graph_type {
for data in dataset.data.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),
})
}
}
})
.render(graph_area, buf);
}
if let Some(legend_area) = layout.legend_area {
buf.set_style(legend_area, original_style);
Block::default()
.borders(Borders::ALL)
.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);
}
}
}

View File

@ -9,11 +9,15 @@ use tui::{
};
use crate::{
app::{event::EventResult, sort_text_table::SortableColumn},
canvas::{DisplayableData, Painter},
app::{data_farmer::DataCollection, event::EventResult, sort_text_table::SortableColumn},
canvas::Painter,
data_conversion::convert_disk_row,
};
use super::{AppScrollWidgetState, CanvasTableWidthState, Component, SortableTextTable, Widget};
use super::{
text_table::TextTableData, AppScrollWidgetState, CanvasTableWidthState, Component,
SortableTextTable, Widget,
};
pub struct DiskWidgetState {
pub scroll_state: AppScrollWidgetState,
@ -52,6 +56,8 @@ impl DiskState {
pub struct DiskTable {
table: SortableTextTable,
bounds: Rect,
display_data: TextTableData,
}
impl Default for DiskTable {
@ -69,6 +75,7 @@ impl Default for DiskTable {
Self {
table,
bounds: Rect::default(),
display_data: Default::default(),
}
}
}
@ -97,8 +104,7 @@ impl Widget for DiskTable {
}
fn draw<B: Backend>(
&mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, data: &DisplayableData,
selected: bool,
&mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, selected: bool,
) {
let block = Block::default()
.border_style(if selected {
@ -108,19 +114,12 @@ impl Widget for DiskTable {
})
.borders(Borders::ALL);
self.set_bounds(area);
let draw_area = block.inner(area);
let (table, widths, mut tui_state) =
self.table
.table
.create_draw_table(painter, &data.disk_data, draw_area);
self.table
.table
.draw_tui_table(painter, f, &self.display_data, block, area, selected);
}
let table = table.highlight_style(if selected {
painter.colours.currently_selected_text_style
} else {
painter.colours.text_style
});
f.render_stateful_widget(table.block(block).widths(&widths), area, &mut tui_state);
fn update_data(&mut self, data_collection: &DataCollection) {
self.display_data = convert_disk_row(data_collection);
}
}

View File

@ -1,9 +1,16 @@
use std::{collections::HashMap, time::Instant};
use std::{borrow::Cow, collections::HashMap, time::Instant};
use crossterm::event::{KeyEvent, MouseEvent};
use tui::layout::Rect;
use tui::{
backend::Backend,
layout::Rect,
widgets::{Block, Borders},
};
use crate::app::event::EventResult;
use crate::{
app::{event::EventResult, time_graph::TimeGraphData, DataCollection},
data_conversion::{convert_mem_data_points, convert_mem_labels, convert_swap_data_points},
};
use super::{Component, TimeGraph, Widget};
@ -48,12 +55,22 @@ impl MemState {
/// around [`TimeGraph`] as of now.
pub struct MemGraph {
graph: TimeGraph,
mem_labels: Option<(String, String)>,
swap_labels: Option<(String, String)>,
mem_data: Vec<(f64, f64)>,
swap_data: Vec<(f64, f64)>,
}
impl MemGraph {
/// Creates a new [`MemGraph`].
pub fn new(graph: TimeGraph) -> Self {
Self { graph }
Self {
graph,
mem_labels: Default::default(),
swap_labels: Default::default(),
mem_data: Default::default(),
swap_data: Default::default(),
}
}
}
@ -79,4 +96,58 @@ impl Widget for MemGraph {
fn get_pretty_name(&self) -> &'static str {
"Memory"
}
fn draw<B: Backend>(
&mut self, painter: &crate::canvas::Painter, f: &mut tui::Frame<'_, B>, area: Rect,
selected: bool,
) {
let block = Block::default()
.border_style(if selected {
painter.colours.highlighted_border_style
} else {
painter.colours.border_style
})
.borders(Borders::ALL);
let mut chart_data = Vec::with_capacity(2);
if let Some((label_percent, label_frac)) = &self.mem_labels {
let mem_label = format!("RAM:{}{}", label_percent, label_frac);
chart_data.push(TimeGraphData {
data: &self.mem_data,
label: Some(mem_label.into()),
style: painter.colours.ram_style,
});
}
if let Some((label_percent, label_frac)) = &self.swap_labels {
let swap_label = format!("SWP:{}{}", label_percent, label_frac);
chart_data.push(TimeGraphData {
data: &self.swap_data,
label: Some(swap_label.into()),
style: painter.colours.swap_style,
});
}
const Y_BOUNDS: [f64; 2] = [0.0, 100.5];
let y_bound_labels: [Cow<'static, str>; 2] = ["0%".into(), "100%".into()];
self.graph.draw_tui_chart(
painter,
f,
&chart_data,
&y_bound_labels,
Y_BOUNDS,
false,
block,
area,
);
}
fn update_data(&mut self, data_collection: &DataCollection) {
self.mem_data = convert_mem_data_points(data_collection, false); // TODO: I think the "is_frozen" part is useless... it's always false now.
self.swap_data = convert_swap_data_points(data_collection, false);
let (memory_labels, swap_labels) = convert_mem_labels(data_collection);
self.mem_labels = memory_labels;
self.swap_labels = swap_labels;
}
}

View File

@ -1,8 +1,24 @@
use std::{collections::HashMap, time::Instant};
use tui::layout::Rect;
use tui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
widgets::{Block, Borders},
Frame,
};
use super::{Component, TimeGraph, Widget};
use crate::{
app::{
data_farmer::DataCollection, text_table::SimpleColumn, time_graph::TimeGraphData,
AppConfigFields, AxisScaling,
},
canvas::Painter,
data_conversion::convert_network_data_points,
units::data_units::DataUnit,
utils::gen_util::*,
};
use super::{Component, TextTable, TimeGraph, Widget};
pub struct NetWidgetState {
pub current_display_time: u64,
@ -57,6 +73,331 @@ impl NetState {
}
}
// --- NEW STUFF BELOW ---
/// Returns the max data point and time given a time.
fn get_max_entry(
rx: &[(f64, f64)], tx: &[(f64, f64)], time_start: f64, network_scale_type: &AxisScaling,
network_use_binary_prefix: bool,
) -> (f64, f64) {
/// Determines a "fake" max value in circumstances where we couldn't find one from the data.
fn calculate_missing_max(
network_scale_type: &AxisScaling, network_use_binary_prefix: bool,
) -> f64 {
match network_scale_type {
AxisScaling::Log => {
if network_use_binary_prefix {
LOG_KIBI_LIMIT
} else {
LOG_KILO_LIMIT
}
}
AxisScaling::Linear => {
if network_use_binary_prefix {
KIBI_LIMIT_F64
} else {
KILO_LIMIT_F64
}
}
}
}
// First, let's shorten our ranges to actually look. We can abuse the fact that our rx and tx arrays
// are sorted, so we can short-circuit our search to filter out only the relevant data points...
let filtered_rx = if let (Some(rx_start), Some(rx_end)) = (
rx.iter().position(|(time, _data)| *time >= time_start),
rx.iter().rposition(|(time, _data)| *time <= 0.0),
) {
Some(&rx[rx_start..=rx_end])
} else {
None
};
let filtered_tx = if let (Some(tx_start), Some(tx_end)) = (
tx.iter().position(|(time, _data)| *time >= time_start),
tx.iter().rposition(|(time, _data)| *time <= 0.0),
) {
Some(&tx[tx_start..=tx_end])
} else {
None
};
// Then, find the maximal rx/tx so we know how to scale, and return it.
match (filtered_rx, filtered_tx) {
(None, None) => (
time_start,
calculate_missing_max(network_scale_type, network_use_binary_prefix),
),
(None, Some(filtered_tx)) => {
match filtered_tx
.iter()
.max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false))
{
Some((best_time, max_val)) => {
if *max_val == 0.0 {
(
time_start,
calculate_missing_max(network_scale_type, network_use_binary_prefix),
)
} else {
(*best_time, *max_val)
}
}
None => (
time_start,
calculate_missing_max(network_scale_type, network_use_binary_prefix),
),
}
}
(Some(filtered_rx), None) => {
match filtered_rx
.iter()
.max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false))
{
Some((best_time, max_val)) => {
if *max_val == 0.0 {
(
time_start,
calculate_missing_max(network_scale_type, network_use_binary_prefix),
)
} else {
(*best_time, *max_val)
}
}
None => (
time_start,
calculate_missing_max(network_scale_type, network_use_binary_prefix),
),
}
}
(Some(filtered_rx), Some(filtered_tx)) => {
match filtered_rx
.iter()
.chain(filtered_tx)
.max_by(|(_, data_a), (_, data_b)| get_ordering(data_a, data_b, false))
{
Some((best_time, max_val)) => {
if *max_val == 0.0 {
(
*best_time,
calculate_missing_max(network_scale_type, network_use_binary_prefix),
)
} else {
(*best_time, *max_val)
}
}
None => (
time_start,
calculate_missing_max(network_scale_type, network_use_binary_prefix),
),
}
}
}
}
/// Returns the required max data point and labels.
fn adjust_network_data_point(
max_entry: f64, network_scale_type: &AxisScaling, network_unit_type: &DataUnit,
network_use_binary_prefix: bool,
) -> (f64, Vec<String>) {
// So, we're going with an approach like this for linear data:
// - Main goal is to maximize the amount of information displayed given a specific height.
// We don't want to drown out some data if the ranges are too far though! Nor do we want to filter
// out too much data...
// - Change the y-axis unit (kilo/kibi, mega/mebi...) dynamically based on max load.
//
// The idea is we take the top value, build our scale such that each "point" is a scaled version of that.
// So for example, let's say I use 390 Mb/s. If I drew 4 segments, it would be 97.5, 195, 292.5, 390, and
// probably something like 438.75?
//
// So, how do we do this in tui-rs? Well, if we are using intervals that tie in perfectly to the max
// value we want... then it's actually not that hard. Since tui-rs accepts a vector as labels and will
// properly space them all out... we just work with that and space it out properly.
//
// Dynamic chart idea based off of FreeNAS's chart design.
//
// ===
//
// For log data, we just use the old method of log intervals (kilo/mega/giga/etc.). Keep it nice and simple.
// Now just check the largest unit we correspond to... then proceed to build some entries from there!
let unit_char = match network_unit_type {
DataUnit::Byte => "B",
DataUnit::Bit => "b",
};
match network_scale_type {
AxisScaling::Linear => {
let (k_limit, m_limit, g_limit, t_limit) = if network_use_binary_prefix {
(
KIBI_LIMIT_F64,
MEBI_LIMIT_F64,
GIBI_LIMIT_F64,
TEBI_LIMIT_F64,
)
} else {
(
KILO_LIMIT_F64,
MEGA_LIMIT_F64,
GIGA_LIMIT_F64,
TERA_LIMIT_F64,
)
};
let bumped_max_entry = max_entry * 1.5; // We use the bumped up version to calculate our unit type.
let (max_value_scaled, unit_prefix, unit_type): (f64, &str, &str) =
if bumped_max_entry < k_limit {
(max_entry, "", unit_char)
} else if bumped_max_entry < m_limit {
(
max_entry / k_limit,
if network_use_binary_prefix { "Ki" } else { "K" },
unit_char,
)
} else if bumped_max_entry < g_limit {
(
max_entry / m_limit,
if network_use_binary_prefix { "Mi" } else { "M" },
unit_char,
)
} else if bumped_max_entry < t_limit {
(
max_entry / g_limit,
if network_use_binary_prefix { "Gi" } else { "G" },
unit_char,
)
} else {
(
max_entry / t_limit,
if network_use_binary_prefix { "Ti" } else { "T" },
unit_char,
)
};
// Finally, build an acceptable range starting from there, using the given height!
// Note we try to put more of a weight on the bottom section vs. the top, since the top has less data.
let base_unit = max_value_scaled;
let labels: Vec<String> = vec![
format!("0{}{}", unit_prefix, unit_type),
format!("{:.1}", base_unit * 0.5),
format!("{:.1}", base_unit),
format!("{:.1}", base_unit * 1.5),
]
.into_iter()
.map(|s| format!("{:>5}", s)) // Pull 5 as the longest legend value is generally going to be 5 digits (if they somehow hit over 5 terabits per second)
.collect();
(bumped_max_entry, labels)
}
AxisScaling::Log => {
let (m_limit, g_limit, t_limit) = if network_use_binary_prefix {
(LOG_MEBI_LIMIT, LOG_GIBI_LIMIT, LOG_TEBI_LIMIT)
} else {
(LOG_MEGA_LIMIT, LOG_GIGA_LIMIT, LOG_TERA_LIMIT)
};
fn get_zero(network_use_binary_prefix: bool, unit_char: &str) -> String {
format!(
"{}0{}",
if network_use_binary_prefix { " " } else { " " },
unit_char
)
}
fn get_k(network_use_binary_prefix: bool, unit_char: &str) -> String {
format!(
"1{}{}",
if network_use_binary_prefix { "Ki" } else { "K" },
unit_char
)
}
fn get_m(network_use_binary_prefix: bool, unit_char: &str) -> String {
format!(
"1{}{}",
if network_use_binary_prefix { "Mi" } else { "M" },
unit_char
)
}
fn get_g(network_use_binary_prefix: bool, unit_char: &str) -> String {
format!(
"1{}{}",
if network_use_binary_prefix { "Gi" } else { "G" },
unit_char
)
}
fn get_t(network_use_binary_prefix: bool, unit_char: &str) -> String {
format!(
"1{}{}",
if network_use_binary_prefix { "Ti" } else { "T" },
unit_char
)
}
fn get_p(network_use_binary_prefix: bool, unit_char: &str) -> String {
format!(
"1{}{}",
if network_use_binary_prefix { "Pi" } else { "P" },
unit_char
)
}
if max_entry < m_limit {
(
m_limit,
vec![
get_zero(network_use_binary_prefix, unit_char),
get_k(network_use_binary_prefix, unit_char),
get_m(network_use_binary_prefix, unit_char),
],
)
} else if max_entry < g_limit {
(
g_limit,
vec![
get_zero(network_use_binary_prefix, unit_char),
get_k(network_use_binary_prefix, unit_char),
get_m(network_use_binary_prefix, unit_char),
get_g(network_use_binary_prefix, unit_char),
],
)
} else if max_entry < t_limit {
(
t_limit,
vec![
get_zero(network_use_binary_prefix, unit_char),
get_k(network_use_binary_prefix, unit_char),
get_m(network_use_binary_prefix, unit_char),
get_g(network_use_binary_prefix, unit_char),
get_t(network_use_binary_prefix, unit_char),
],
)
} else {
// I really doubt anyone's transferring beyond petabyte speeds...
(
if network_use_binary_prefix {
LOG_PEBI_LIMIT
} else {
LOG_PETA_LIMIT
},
vec![
get_zero(network_use_binary_prefix, unit_char),
get_k(network_use_binary_prefix, unit_char),
get_m(network_use_binary_prefix, unit_char),
get_g(network_use_binary_prefix, unit_char),
get_t(network_use_binary_prefix, unit_char),
get_p(network_use_binary_prefix, unit_char),
],
)
}
}
}
}
/// A struct containing useful cached information for a [`NetGraph`].
#[derive(Clone)]
pub struct NetGraphCache {
@ -83,17 +424,48 @@ pub struct NetGraph {
// Cached details for drawing purposes; probably want to move at some point...
draw_cache: NetGraphCacheState,
pub rx_display: String,
pub tx_display: String,
pub total_rx_display: String,
pub total_tx_display: String,
pub network_data_rx: Vec<(f64, f64)>,
pub network_data_tx: Vec<(f64, f64)>,
pub scale_type: AxisScaling,
pub unit_type: DataUnit,
pub use_binary_prefix: bool,
hide_legend: bool,
}
impl NetGraph {
/// Creates a new [`NetGraph`].
pub fn new(graph: TimeGraph) -> Self {
/// Creates a new [`NetGraph`] given a [`AppConfigFields`].
pub fn from_config(app_config_fields: &AppConfigFields) -> Self {
let graph = TimeGraph::from_config(app_config_fields);
Self {
graph,
draw_cache: NetGraphCacheState::Uncached,
rx_display: Default::default(),
tx_display: Default::default(),
total_rx_display: Default::default(),
total_tx_display: Default::default(),
network_data_rx: Default::default(),
network_data_tx: Default::default(),
scale_type: app_config_fields.network_scale_type.clone(),
unit_type: app_config_fields.network_unit_type.clone(),
use_binary_prefix: app_config_fields.network_use_binary_prefix,
hide_legend: false,
}
}
/// Hides the legend. Only really useful for [`OldNetGraph`].
pub fn hide_legend(mut self) -> Self {
self.hide_legend = true;
self
}
/// Updates the associated cache on a [`NetGraph`].
pub fn set_cache(&mut self, area: Rect, max_range: f64, labels: Vec<String>, time_start: f64) {
self.draw_cache = NetGraphCacheState::Cached {
@ -171,20 +543,110 @@ impl Widget for NetGraph {
fn get_pretty_name(&self) -> &'static str {
"Network"
}
fn draw<B: Backend>(
&mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, selected: bool,
) {
let block = Block::default()
.border_style(if selected {
painter.colours.highlighted_border_style
} else {
painter.colours.border_style
})
.borders(Borders::ALL);
let chart_data = vec![
TimeGraphData {
data: &self.network_data_rx,
label: if self.hide_legend {
None
} else {
Some(self.rx_display.clone().into())
},
style: painter.colours.rx_style,
},
TimeGraphData {
data: &self.network_data_tx,
label: if self.hide_legend {
None
} else {
Some(self.tx_display.clone().into())
},
style: painter.colours.tx_style,
},
];
let (_best_time, max_entry) = get_max_entry(
&self.network_data_rx,
&self.network_data_tx,
-(self.graph.get_current_display_time() as f64),
&self.scale_type,
self.use_binary_prefix,
);
let (max_range, labels) = adjust_network_data_point(
max_entry,
&self.scale_type,
&self.unit_type,
self.use_binary_prefix,
);
let y_bounds = [0.0, max_range];
let y_bound_labels = labels.into_iter().map(Into::into).collect::<Vec<_>>();
self.graph.draw_tui_chart(
painter,
f,
&chart_data,
&y_bound_labels,
y_bounds,
false,
block,
area,
);
}
fn update_data(&mut self, data_collection: &DataCollection) {
let network_data = convert_network_data_points(
data_collection,
false, // TODO: I think the is_frozen here is also useless; see mem and cpu
false,
&self.scale_type,
&self.unit_type,
self.use_binary_prefix,
);
self.network_data_rx = network_data.rx;
self.network_data_tx = network_data.tx;
self.rx_display = network_data.rx_display;
self.tx_display = network_data.tx_display;
if let Some(total_rx_display) = network_data.total_rx_display {
self.total_rx_display = total_rx_display;
}
if let Some(total_tx_display) = network_data.total_tx_display {
self.total_tx_display = total_tx_display;
}
}
}
/// A widget denoting network usage via a graph and a separate, single row table. This is built on [`NetGraph`],
/// and the main difference is that it also contains a bounding box for the graph + text.
pub struct OldNetGraph {
net_graph: NetGraph,
table: TextTable,
bounds: Rect,
}
impl OldNetGraph {
/// Creates a new [`OldNetGraph`].
pub fn new(graph: TimeGraph) -> Self {
/// Creates a new [`OldNetGraph`] from a [`AppConfigFields`].
pub fn from_config(config: &AppConfigFields) -> Self {
Self {
net_graph: NetGraph::new(graph),
net_graph: NetGraph::from_config(config).hide_legend(),
table: TextTable::new(vec![
SimpleColumn::new_flex("RX".into(), 0.25),
SimpleColumn::new_flex("TX".into(), 0.25),
SimpleColumn::new_flex("Total RX".into(), 0.25),
SimpleColumn::new_flex("Total TX".into(), 0.25),
])
.unselectable(),
bounds: Rect::default(),
}
}
@ -216,4 +678,70 @@ impl Widget for OldNetGraph {
fn get_pretty_name(&self) -> &'static str {
"Network"
}
fn draw<B: Backend>(
&mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, selected: bool,
) {
const CONSTRAINTS: [Constraint; 2] = [Constraint::Min(0), Constraint::Length(4)];
let split_area = Layout::default()
.direction(Direction::Vertical)
.constraints(CONSTRAINTS)
.split(area);
let graph_area = split_area[0];
let table_area = split_area[1];
self.net_graph.draw(painter, f, graph_area, selected);
let table_block = Block::default()
.border_style(if selected {
painter.colours.highlighted_border_style
} else {
painter.colours.border_style
})
.borders(Borders::ALL);
self.table.draw_tui_table(
painter,
f,
&vec![vec![
(
self.net_graph.rx_display.clone().into(),
None,
Some(painter.colours.rx_style),
),
(
self.net_graph.tx_display.clone().into(),
None,
Some(painter.colours.tx_style),
),
(self.net_graph.total_rx_display.clone().into(), None, None),
(self.net_graph.total_tx_display.clone().into(), None, None),
]],
table_block,
table_area,
selected,
);
}
fn update_data(&mut self, data_collection: &DataCollection) {
let network_data = convert_network_data_points(
data_collection,
false, // TODO: I think the is_frozen here is also useless; see mem and cpu
true,
&self.net_graph.scale_type,
&self.net_graph.unit_type,
self.net_graph.use_binary_prefix,
);
self.net_graph.network_data_rx = network_data.rx;
self.net_graph.network_data_tx = network_data.tx;
self.net_graph.rx_display = network_data.rx_display;
self.net_graph.tx_display = network_data.tx_display;
if let Some(total_rx_display) = network_data.total_rx_display {
self.net_graph.total_rx_display = total_rx_display;
}
if let Some(total_tx_display) = network_data.total_tx_display {
self.net_graph.total_tx_display = total_tx_display;
}
}
}

View File

@ -14,16 +14,17 @@ use crate::{
app::{
event::{EventResult, MultiKey, MultiKeyResult},
query::*,
DataCollection,
},
canvas::{DisplayableData, Painter},
canvas::Painter,
data_harvester::processes::{self, ProcessSorting},
options::ProcessDefaults,
};
use ProcessSorting::*;
use super::{
AppScrollWidgetState, CanvasTableWidthState, Component, CursorDirection, ScrollDirection,
SortableTextTable, TextInput, TextTable, Widget,
text_table::TextTableData, AppScrollWidgetState, CanvasTableWidthState, Component,
CursorDirection, ScrollDirection, SortableTextTable, TextInput, TextTable, Widget,
};
/// AppSearchState deals with generic searching (I might do this in the future).
@ -653,6 +654,8 @@ pub struct ProcessManager {
show_search: bool,
search_modifiers: SearchModifiers,
display_data: TextTableData,
}
impl ProcessManager {
@ -671,6 +674,7 @@ impl ProcessManager {
show_sort: false,
show_search: false,
search_modifiers: SearchModifiers::default(),
display_data: Default::default(),
};
manager.set_tree_mode(process_defaults.is_tree);
@ -813,14 +817,29 @@ impl Component for ProcessManager {
match &event.kind {
MouseEventKind::Down(MouseButton::Left) => {
if self.process_table.does_intersect_mouse(&event) {
self.selected = ProcessManagerSelection::Processes;
self.process_table.handle_mouse_event(event)
if let ProcessManagerSelection::Processes = self.selected {
self.process_table.handle_mouse_event(event)
} else {
self.selected = ProcessManagerSelection::Processes;
self.process_table.handle_mouse_event(event);
EventResult::Redraw
}
} else if self.sort_table.does_intersect_mouse(&event) {
self.selected = ProcessManagerSelection::Sort;
self.sort_table.handle_mouse_event(event)
if let ProcessManagerSelection::Sort = self.selected {
self.sort_table.handle_mouse_event(event)
} else {
self.selected = ProcessManagerSelection::Sort;
self.sort_table.handle_mouse_event(event);
EventResult::Redraw
}
} else if self.search_input.does_intersect_mouse(&event) {
self.selected = ProcessManagerSelection::Search;
self.search_input.handle_mouse_event(event)
if let ProcessManagerSelection::Search = self.selected {
self.search_input.handle_mouse_event(event)
} else {
self.selected = ProcessManagerSelection::Search;
self.search_input.handle_mouse_event(event);
EventResult::Redraw
}
} else {
EventResult::NoRedraw
}
@ -841,8 +860,7 @@ impl Widget for ProcessManager {
}
fn draw<B: Backend>(
&mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, data: &DisplayableData,
selected: bool,
&mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, selected: bool,
) {
let block = Block::default()
.border_style(if selected {
@ -852,24 +870,15 @@ impl Widget for ProcessManager {
})
.borders(Borders::ALL);
self.set_bounds(area);
let draw_area = block.inner(area);
let (process_table, widths, mut tui_state) = self.process_table.table.create_draw_table(
self.process_table.table.draw_tui_table(
painter,
&vec![], // TODO: Fix this
draw_area,
);
let process_table = process_table.highlight_style(if selected {
painter.colours.currently_selected_text_style
} else {
painter.colours.text_style
});
f.render_stateful_widget(
process_table.block(block).widths(&widths),
f,
&self.display_data, // TODO: Fix this
block,
area,
&mut tui_state,
selected,
);
}
fn update_data(&mut self, data_collection: &DataCollection) {}
}

View File

@ -9,11 +9,18 @@ use tui::{
};
use crate::{
app::{event::EventResult, sort_text_table::SortableColumn},
canvas::{DisplayableData, Painter},
app::{
data_farmer::DataCollection, data_harvester::temperature::TemperatureType,
event::EventResult, sort_text_table::SortableColumn,
},
canvas::Painter,
data_conversion::convert_temp_row,
};
use super::{AppScrollWidgetState, CanvasTableWidthState, Component, SortableTextTable, Widget};
use super::{
text_table::TextTableData, AppScrollWidgetState, CanvasTableWidthState, Component,
SortableTextTable, Widget,
};
pub struct TempWidgetState {
pub scroll_state: AppScrollWidgetState,
@ -52,6 +59,8 @@ impl TempState {
pub struct TempTable {
table: SortableTextTable,
bounds: Rect,
display_data: TextTableData,
temp_type: TemperatureType,
}
impl Default for TempTable {
@ -65,10 +74,20 @@ impl Default for TempTable {
Self {
table,
bounds: Rect::default(),
display_data: Default::default(),
temp_type: TemperatureType::default(),
}
}
}
impl TempTable {
/// Sets the [`TemperatureType`] for the [`TempTable`].
pub fn set_temp_type(mut self, temp_type: TemperatureType) -> Self {
self.temp_type = temp_type;
self
}
}
impl Component for TempTable {
fn handle_key_event(&mut self, event: KeyEvent) -> EventResult {
self.table.handle_key_event(event)
@ -93,8 +112,7 @@ impl Widget for TempTable {
}
fn draw<B: Backend>(
&mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, data: &DisplayableData,
selected: bool,
&mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, selected: bool,
) {
let block = Block::default()
.border_style(if selected {
@ -104,19 +122,12 @@ impl Widget for TempTable {
})
.borders(Borders::ALL); // TODO: Also do the scrolling indicator!
self.set_bounds(area);
let draw_area = block.inner(area);
let (table, widths, mut tui_state) =
self.table
.table
.create_draw_table(painter, &data.temp_sensor_data, draw_area);
self.table
.table
.draw_tui_table(painter, f, &self.display_data, block, area, selected);
}
let table = table.highlight_style(if selected {
painter.colours.currently_selected_text_style
} else {
painter.colours.text_style
});
f.render_stateful_widget(table.block(block).widths(&widths), area, &mut tui_state);
fn update_data(&mut self, data_collection: &DataCollection) {
self.display_data = convert_temp_row(data_collection, &self.temp_type);
}
}

View File

@ -0,0 +1,2 @@
pub mod custom_legend_chart;
pub use custom_legend_chart::TimeChart;

View File

@ -0,0 +1,596 @@
use float_ord::FloatOrd;
use std::{borrow::Cow, cmp::max};
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
title: Option<Spans<'a>>,
/// Bounds for the axis (all data points outside these limits will not be represented)
bounds: [f64; 2],
/// A list of labels to put to the left or below the axis
labels: Option<Vec<Span<'a>>>,
/// The style used to draw the axis itself
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(),
}
}
}
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(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,
}
impl Default for ChartLayout {
fn default() -> ChartLayout {
ChartLayout {
title_x: None,
title_y: None,
label_x: None,
label_y: None,
axis_x: None,
axis_y: None,
legend_area: None,
graph_area: Rect::default(),
}
}
}
/// 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 optimizing out redundant draws in the x-bounds.
/// - Automatic interpolation to points that fall *just* outside of the screen.
///
/// To add:
/// - 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),
}
impl<'a> TimeChart<'a> {
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: (Constraint::Ratio(1, 4), Constraint::Ratio(1, 4)),
}
}
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) {
/// 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)
}
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 = FloatOrd(self.x_axis.bounds[0]);
let end_bound = FloatOrd(self.x_axis.bounds[1]);
let (start_index, interpolate_start) = match dataset
.data
.binary_search_by(|(x, _y)| FloatOrd(*x).cmp(&start_bound))
{
Ok(index) => (index, None),
Err(index) => (index, index.checked_sub(1)),
};
let (end_index, interpolate_end) = match dataset
.data
.binary_search_by(|(x, _y)| FloatOrd(*x).cmp(&end_bound))
{
Ok(index) => (index, None),
Err(index) => (
index,
if index + 1 < dataset.data.len() {
Some(index + 1)
} else {
None
},
),
};
// debug!("Start: {}, inter: {:?}", start_index, interpolate_start);
// debug!("End: {}, inter: {:?}", end_index, interpolate_end);
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),
});
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),
});
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);
}
}
}

View File

@ -1,4 +1,4 @@
use std::{borrow::Cow, collections::HashMap, str::FromStr};
use std::{collections::HashMap, str::FromStr};
use fxhash::FxHashMap;
use indextree::{Arena, NodeId};
@ -16,7 +16,13 @@ use canvas_colours::*;
use dialogs::*;
use crate::{
app::{self, layout_manager::LayoutNode, widgets::Widget, TmpBottomWidget},
app::{
self,
layout_manager::LayoutNode,
text_table::TextTableData,
widgets::{Component, Widget},
TmpBottomWidget,
},
constants::*,
data_conversion::{ConvertedBatteryData, ConvertedCpuData, ConvertedProcessData},
options::Config,
@ -41,16 +47,16 @@ pub struct DisplayableData {
pub total_tx_display: String,
pub network_data_rx: Vec<Point>,
pub network_data_tx: Vec<Point>,
pub disk_data: Vec<Vec<(Cow<'static, str>, Option<Cow<'static, str>>)>>,
pub temp_sensor_data: Vec<Vec<(Cow<'static, str>, Option<Cow<'static, str>>)>>,
pub disk_data: TextTableData,
pub temp_sensor_data: TextTableData,
pub single_process_data: HashMap<Pid, ConvertedProcessData>, // Contains single process data, key is PID
pub stringified_process_data_map: HashMap<NodeId, Vec<(Vec<(String, Option<String>)>, bool)>>, // Represents the row and whether it is disabled, key is the widget ID
pub mem_labels: Option<(String, String)>,
pub swap_labels: Option<(String, String)>,
pub mem_data: Vec<Point>, // TODO: Switch this and all data points over to a better data structure...
pub mem_data: Vec<Point>,
pub swap_data: Vec<Point>,
pub load_avg_data: [f32; 3],
pub cpu_data: Vec<ConvertedCpuData>,
pub battery_data: Vec<ConvertedBatteryData>,
@ -336,12 +342,12 @@ impl Painter {
self.draw_frozen_indicator(&mut f, frozen_draw_loc);
}
let canvas_data = &app_state.canvas_data;
if let Some(current_widget) = app_state
.widget_lookup_map
.get_mut(&app_state.selected_widget)
{
current_widget.draw(self, f, draw_area, canvas_data, true);
current_widget.set_bounds(draw_area);
current_widget.draw(self, f, draw_area, true);
}
} else {
/// A simple traversal through the `arena`.
@ -392,7 +398,8 @@ impl Painter {
}
LayoutNode::Widget => {
if let Some(widget) = lookup_map.get_mut(&node) {
widget.draw(painter, f, area, canvas_data, selected_id == node);
widget.set_bounds(area);
widget.draw(painter, f, area, selected_id == node);
}
}
}

View File

@ -1,8 +1,11 @@
//! This mainly concerns converting collected data into things that the canvas
//! can actually handle.
use crate::app::data_harvester::temperature::TemperatureType;
use crate::app::text_table::TextTableData;
use crate::app::DataCollection;
use crate::{app::AxisScaling, units::data_units::DataUnit, Pid};
use crate::{
app::{data_farmer, data_harvester, AppState, ProcWidgetState},
app::{data_harvester, ProcWidgetState},
utils::{self, gen_util::*},
};
use data_harvester::processes::ProcessSorting;
@ -85,15 +88,12 @@ pub struct ConvertedCpuData {
}
pub fn convert_temp_row(
app: &AppState,
) -> Vec<Vec<(Cow<'static, str>, Option<Cow<'static, str>>)>> {
let current_data = &app.data_collection;
let temp_type = &app.app_config_fields.temperature_type;
current_data: &DataCollection, temp_type: &TemperatureType,
) -> TextTableData {
if current_data.temp_harvest.is_empty() {
vec![vec![
("No Sensors Found".into(), Some("N/A".into())),
("".into(), None),
("No Sensors Found".into(), Some("N/A".into()), None),
("".into(), None, None),
]]
} else {
let (unit_long, unit_short) = match temp_type {
@ -108,10 +108,11 @@ pub fn convert_temp_row(
.map(|temp_harvest| {
let val = temp_harvest.temperature.ceil().to_string();
vec![
(temp_harvest.name.clone().into(), None),
(temp_harvest.name.clone().into(), None, None),
(
format!("{}{}", val, unit_long).into(),
Some(format!("{}{}", val, unit_short).into()),
None,
),
]
})
@ -119,13 +120,11 @@ pub fn convert_temp_row(
}
}
pub fn convert_disk_row(
current_data: &data_farmer::DataCollection,
) -> Vec<Vec<(Cow<'static, str>, Option<Cow<'static, str>>)>> {
pub fn convert_disk_row(current_data: &DataCollection) -> TextTableData {
if current_data.disk_harvest.is_empty() {
vec![vec![
("No Disks Found".into(), Some("N/A".into())),
("".into(), None),
("No Disks Found".into(), Some("N/A".into()), None),
("".into(), None, None),
]]
} else {
current_data
@ -164,13 +163,13 @@ pub fn convert_disk_row(
};
vec![
(disk.name.clone().into(), None),
(disk.mount_point.clone().into(), None),
(usage_fmt, None),
(free_space_fmt, None),
(total_space_fmt, None),
(io_read.clone().into(), None),
(io_write.clone().into(), None),
(disk.name.clone().into(), None, None),
(disk.mount_point.clone().into(), None, None),
(usage_fmt, None, None),
(free_space_fmt, None, None),
(total_space_fmt, None, None),
(io_read.clone().into(), None, None),
(io_write.clone().into(), None, None),
]
})
.collect::<Vec<_>>()
@ -178,8 +177,7 @@ pub fn convert_disk_row(
}
pub fn convert_cpu_data_points(
current_data: &data_farmer::DataCollection, existing_cpu_data: &mut Vec<ConvertedCpuData>,
is_frozen: bool,
current_data: &DataCollection, existing_cpu_data: &mut Vec<ConvertedCpuData>, is_frozen: bool,
) {
let current_time = if is_frozen {
if let Some(frozen_instant) = current_data.frozen_instant {
@ -257,9 +255,7 @@ pub fn convert_cpu_data_points(
}
}
pub fn convert_mem_data_points(
current_data: &data_farmer::DataCollection, is_frozen: bool,
) -> Vec<Point> {
pub fn convert_mem_data_points(current_data: &DataCollection, is_frozen: bool) -> Vec<Point> {
let mut result: Vec<Point> = Vec::new();
let current_time = if is_frozen {
if let Some(frozen_instant) = current_data.frozen_instant {
@ -285,9 +281,7 @@ pub fn convert_mem_data_points(
result
}
pub fn convert_swap_data_points(
current_data: &data_farmer::DataCollection, is_frozen: bool,
) -> Vec<Point> {
pub fn convert_swap_data_points(current_data: &DataCollection, is_frozen: bool) -> Vec<Point> {
let mut result: Vec<Point> = Vec::new();
let current_time = if is_frozen {
if let Some(frozen_instant) = current_data.frozen_instant {
@ -314,7 +308,7 @@ pub fn convert_swap_data_points(
}
pub fn convert_mem_labels(
current_data: &data_farmer::DataCollection,
current_data: &DataCollection,
) -> (Option<(String, String)>, Option<(String, String)>) {
/// Returns the unit type and denominator for given total amount of memory in kibibytes.
fn return_unit_and_denominator_for_mem_kib(mem_total_kib: u64) -> (&'static str, f64) {
@ -333,6 +327,7 @@ pub fn convert_mem_labels(
}
}
// TODO: Should probably make this only return none if no data is left/visible?
(
if current_data.memory_harvest.mem_total_in_kib > 0 {
Some((
@ -384,7 +379,7 @@ pub fn convert_mem_labels(
}
pub fn get_rx_tx_data_points(
current_data: &data_farmer::DataCollection, is_frozen: bool, network_scale_type: &AxisScaling,
current_data: &DataCollection, is_frozen: bool, network_scale_type: &AxisScaling,
network_unit_type: &DataUnit, network_use_binary_prefix: bool,
) -> (Vec<Point>, Vec<Point>) {
let mut rx: Vec<Point> = Vec::new();
@ -439,7 +434,7 @@ pub fn get_rx_tx_data_points(
}
pub fn convert_network_data_points(
current_data: &data_farmer::DataCollection, is_frozen: bool, need_four_points: bool,
current_data: &DataCollection, is_frozen: bool, need_four_points: bool,
network_scale_type: &AxisScaling, network_unit_type: &DataUnit,
network_use_binary_prefix: bool,
) -> ConvertedNetworkData {
@ -620,7 +615,7 @@ fn get_disk_io_strings(
/// Because we needed to UPDATE data entries rather than REPLACING entries, we instead update
/// the existing vector.
pub fn convert_process_data(
current_data: &data_farmer::DataCollection,
current_data: &DataCollection,
existing_converted_process_data: &mut HashMap<Pid, ConvertedProcessData>,
#[cfg(target_family = "unix")] user_table: &mut data_harvester::processes::UserTable,
) {
@ -1379,9 +1374,7 @@ pub fn group_process_data(
.collect::<Vec<_>>()
}
pub fn convert_battery_harvest(
current_data: &data_farmer::DataCollection,
) -> Vec<ConvertedBatteryData> {
pub fn convert_battery_harvest(current_data: &DataCollection) -> Vec<ConvertedBatteryData> {
current_data
.battery_harvest
.iter()