From f02daa0a2b2b38934bcb2189373afec85862048d Mon Sep 17 00:00:00 2001 From: ClementTsang Date: Mon, 27 Sep 2021 16:28:48 -0400 Subject: [PATCH] refactor: various bug fixes and code removal --- Cargo.lock | 12 - Cargo.toml | 1 - src/app.rs | 16 +- src/app/data_farmer.rs | 13 +- src/app/data_harvester/processes.rs | 6 +- src/app/data_harvester/processes/linux.rs | 1 + src/app/data_harvester/processes/macos.rs | 1 + src/app/data_harvester/processes/windows.rs | 1 + src/app/layout_manager.rs | 135 +-------- src/app/process_killer.rs | 81 +----- src/app/process_killer/unix.rs | 31 ++ src/app/process_killer/windows.rs | 40 +++ src/app/widgets.rs | 5 - src/app/widgets/base/sort_menu.rs | 5 + src/app/widgets/base/sort_text_table.rs | 4 +- src/app/widgets/base/text_table.rs | 5 +- src/app/widgets/bottom_widgets/battery.rs | 1 + src/app/widgets/bottom_widgets/cpu.rs | 3 +- src/app/widgets/bottom_widgets/disk.rs | 14 +- src/app/widgets/bottom_widgets/net.rs | 8 +- src/app/widgets/bottom_widgets/process.rs | 19 +- src/app/widgets/bottom_widgets/temp.rs | 12 +- src/canvas.rs | 50 +--- src/constants.rs | 10 - src/data_conversion.rs | 307 +------------------- src/lib.rs | 2 +- src/options.rs | 18 +- src/utils/error.rs | 3 - src/utils/gen_util.rs | 11 - 29 files changed, 154 insertions(+), 661 deletions(-) create mode 100644 src/app/process_killer/unix.rs create mode 100644 src/app/process_killer/windows.rs diff --git a/Cargo.lock b/Cargo.lock index 2dbda550..5973baf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -266,7 +266,6 @@ dependencies = [ "thiserror", "toml", "tui", - "typed-builder", "unicode-segmentation", "unicode-width", "winapi", @@ -1529,17 +1528,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "typed-builder" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a46ee5bd706ff79131be9c94e7edcb82b703c487766a114434e5790361cf08c5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "typenum" version = "1.14.0" diff --git a/Cargo.toml b/Cargo.toml index e1612171..d44d54f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,6 @@ thiserror = "1.0.24" textwrap = "0.14.2" toml = "0.5.8" tui = { version = "0.16.0", features = ["crossterm"], default-features = false } -typed-builder = "0.9.0" unicode-segmentation = "1.8.0" unicode-width = "0.1" diff --git a/src/app.rs b/src/app.rs index 4b58b014..d81b88fa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,9 +19,7 @@ pub use filter::*; use layout_manager::*; pub use widgets::*; -use crate::{ - canvas, constants, units::data_units::DataUnit, utils::error::Result, BottomEvent, Pid, -}; +use crate::{constants, units::data_units::DataUnit, utils::error::Result, BottomEvent, Pid}; use self::event::{ComponentEventResult, EventResult, ReturnSignal}; @@ -87,7 +85,7 @@ pub struct AppConfigFields { pub hide_time: bool, pub autohide_time: bool, pub use_old_network_legend: bool, - pub table_gap: u16, // TODO: [Config, Refactor] Just make this a bool... + pub table_gap: bool, pub disable_click: bool, pub no_write: bool, pub show_table_scroll_position: bool, @@ -101,7 +99,7 @@ pub struct AppConfigFields { /// the data collected at that instant. pub enum FrozenState { NotFrozen, - Frozen(DataCollection), + Frozen(Box), } impl Default for FrozenState { @@ -115,8 +113,6 @@ pub struct AppState { to_delete_process_list: Option<(String, Vec)>, - pub canvas_data: canvas::DisplayableData, - pub data_collection: DataCollection, pub is_expanded: bool, @@ -167,7 +163,6 @@ impl AppState { // Use defaults. dd_err: Default::default(), to_delete_process_list: Default::default(), - canvas_data: Default::default(), data_collection: Default::default(), is_expanded: Default::default(), delete_dialog_state: Default::default(), @@ -186,7 +181,7 @@ impl AppState { if matches!(self.frozen_state, FrozenState::Frozen(_)) { self.frozen_state = FrozenState::NotFrozen; } else { - self.frozen_state = FrozenState::Frozen(self.data_collection.clone()); + self.frozen_state = FrozenState::Frozen(Box::new(self.data_collection.clone())); } } @@ -418,7 +413,6 @@ impl AppState { if was_id_already_selected { returned_result = self.convert_widget_event_result(result); - break; } else { // If the weren't equal, *force* a redraw, and correct the layout tree. correct_layout_last_selections( @@ -427,8 +421,8 @@ impl AppState { ); let _ = self.convert_widget_event_result(result); returned_result = EventResult::Redraw; - break; } + break; } SelectableType::Unselectable => { let result = widget.handle_mouse_event(event); diff --git a/src/app/data_farmer.rs b/src/app/data_farmer.rs index 6b850f8b..83b53d29 100644 --- a/src/app/data_farmer.rs +++ b/src/app/data_farmer.rs @@ -26,17 +26,14 @@ use crate::{ }; use regex::Regex; -pub type TimeOffset = f64; -pub type Value = f64; - #[derive(Clone, Debug, Default)] pub struct TimedData { - pub rx_data: Value, - pub tx_data: Value, - pub cpu_data: Vec, + pub rx_data: f64, + pub tx_data: f64, + pub cpu_data: Vec, pub load_avg_data: [f32; 3], - pub mem_data: Option, - pub swap_data: Option, + pub mem_data: Option, + pub swap_data: Option, } /// AppCollection represents the pooled data stored within the main app diff --git a/src/app/data_harvester/processes.rs b/src/app/data_harvester/processes.rs index 4b09a078..1ee60123 100644 --- a/src/app/data_harvester/processes.rs +++ b/src/app/data_harvester/processes.rs @@ -78,7 +78,8 @@ impl Default for ProcessSorting { #[derive(Debug, Clone, Default)] pub struct ProcessHarvest { pub pid: Pid, - pub parent_pid: Option, // Remember, parent_pid 0 is root... + pub parent_pid: Option, + pub children_pids: Vec, pub cpu_usage_percent: f64, pub mem_usage_percent: f64, pub mem_usage_bytes: u64, @@ -93,10 +94,11 @@ pub struct ProcessHarvest { pub process_state: String, pub process_state_char: char, - /// This is the *effective* user ID. + /// This is the *effective* user ID. This is only used on Unix platforms. #[cfg(target_family = "unix")] pub uid: libc::uid_t, + /// This is the process' user. This is only used on Unix platforms. #[cfg(target_family = "unix")] pub user: Cow<'static, str>, } diff --git a/src/app/data_harvester/processes/linux.rs b/src/app/data_harvester/processes/linux.rs index aad67adf..2e9384f5 100644 --- a/src/app/data_harvester/processes/linux.rs +++ b/src/app/data_harvester/processes/linux.rs @@ -203,6 +203,7 @@ fn read_proc( ProcessHarvest { pid: process.pid, parent_pid, + children_pids: vec![], cpu_usage_percent, mem_usage_percent, mem_usage_bytes, diff --git a/src/app/data_harvester/processes/macos.rs b/src/app/data_harvester/processes/macos.rs index a65f2627..d5dffe85 100644 --- a/src/app/data_harvester/processes/macos.rs +++ b/src/app/data_harvester/processes/macos.rs @@ -89,6 +89,7 @@ pub fn get_process_data( process_vector.push(ProcessHarvest { pid: process_val.pid(), parent_pid: process_val.parent(), + children_pids: vec![], name, command, mem_usage_percent: if mem_total_kb > 0 { diff --git a/src/app/data_harvester/processes/windows.rs b/src/app/data_harvester/processes/windows.rs index 08cde500..763f9cf8 100644 --- a/src/app/data_harvester/processes/windows.rs +++ b/src/app/data_harvester/processes/windows.rs @@ -58,6 +58,7 @@ pub fn get_process_data( process_vector.push(ProcessHarvest { pid: process_val.pid(), parent_pid: process_val.parent(), + children_pids: vec![], name, command, mem_usage_percent: if mem_total_kb > 0 { diff --git a/src/app/layout_manager.rs b/src/app/layout_manager.rs index bde3febb..7f0dd663 100644 --- a/src/app/layout_manager.rs +++ b/src/app/layout_manager.rs @@ -13,7 +13,6 @@ use fxhash::FxHashMap; use indextree::{Arena, NodeId}; use std::cmp::min; use tui::layout::Rect; -use typed_builder::*; use crate::app::widgets::Widget; @@ -21,130 +20,6 @@ use super::{ event::SelectionAction, AppConfigFields, CpuGraph, TimeGraph, TmpBottomWidget, UsedWidgets, }; -/// Represents a more usable representation of the layout, derived from the -/// config. -#[derive(Clone, Debug)] -pub struct BottomLayout { - pub rows: Vec, - pub total_row_height_ratio: u32, -} - -/// Represents a single row in the layout. -#[derive(Clone, Debug, TypedBuilder)] -pub struct OldBottomRow { - pub children: Vec, - - #[builder(default = 1)] - pub total_col_ratio: u32, - - #[builder(default = 1)] - pub row_height_ratio: u32, - - #[builder(default = false)] - pub canvas_handle_height: bool, - - #[builder(default = false)] - pub flex_grow: bool, -} - -/// Represents a single column in the layout. We assume that even if the column -/// contains only ONE element, it is still a column (rather than either a col or -/// a widget, as per the config, for simplicity's sake). -#[derive(Clone, Debug, TypedBuilder)] -pub struct OldBottomCol { - pub children: Vec, - - #[builder(default = 1)] - pub total_col_row_ratio: u32, - - #[builder(default = 1)] - pub col_width_ratio: u32, - - #[builder(default = false)] - pub canvas_handle_width: bool, - - #[builder(default = false)] - pub flex_grow: bool, -} - -#[derive(Clone, Default, Debug, TypedBuilder)] -pub struct BottomColRow { - pub children: Vec, - - #[builder(default = 1)] - pub total_widget_ratio: u32, - - #[builder(default = 1)] - pub col_row_height_ratio: u32, - - #[builder(default = false)] - pub canvas_handle_height: bool, - - #[builder(default = false)] - pub flex_grow: bool, -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum WidgetDirection { - Left, - Right, - Up, - Down, -} - -impl WidgetDirection { - pub fn is_opposite(&self, other_direction: &WidgetDirection) -> bool { - match &self { - WidgetDirection::Left => *other_direction == WidgetDirection::Right, - WidgetDirection::Right => *other_direction == WidgetDirection::Left, - WidgetDirection::Up => *other_direction == WidgetDirection::Down, - WidgetDirection::Down => *other_direction == WidgetDirection::Up, - } - } -} - -/// Represents a single widget. -#[derive(Debug, Default, Clone, TypedBuilder)] -pub struct BottomWidget { - pub widget_type: BottomWidgetType, - pub widget_id: u64, - - #[builder(default = 1)] - pub width_ratio: u32, - - #[builder(default = None)] - pub left_neighbour: Option, - - #[builder(default = None)] - pub right_neighbour: Option, - - #[builder(default = None)] - pub up_neighbour: Option, - - #[builder(default = None)] - pub down_neighbour: Option, - - /// If set to true, the canvas will override any ratios. - #[builder(default = false)] - pub canvas_handle_width: bool, - - /// Whether we want this widget to take up all available room (and ignore any ratios). - #[builder(default = false)] - pub flex_grow: bool, - - /// The value is the direction to bounce, as well as the parent offset. - #[builder(default = None)] - pub parent_reflector: Option<(WidgetDirection, u64)>, - - /// Top left corner when drawn, for mouse click detection. (x, y) - #[builder(default = None)] - pub top_left_corner: Option<(u16, u16)>, - - /// Bottom right corner when drawn, for mouse click detection. (x, y) - #[builder(default = None)] - pub bottom_right_corner: Option<(u16, u16)>, -} - #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum BottomWidgetType { Empty, @@ -238,8 +113,6 @@ Supported widget names: } } -// --- New stuff --- - /// Represents a row in the layout tree. #[derive(Debug, PartialEq, Eq, Clone)] pub struct RowLayout { @@ -380,7 +253,7 @@ pub fn create_layout_tree( BottomWidgetType::Proc => { widget_lookup_map.insert( widget_id, - ProcessManager::new(process_defaults) + ProcessManager::new(process_defaults, app_config_fields) .width(width) .height(height) .basic_mode(app_config_fields.use_basic_mode) @@ -391,7 +264,7 @@ pub fn create_layout_tree( BottomWidgetType::Temp => { widget_lookup_map.insert( widget_id, - TempTable::default() + TempTable::from_config(app_config_fields) .set_temp_type(app_config_fields.temperature_type.clone()) .width(width) .height(height) @@ -403,7 +276,7 @@ pub fn create_layout_tree( BottomWidgetType::Disk => { widget_lookup_map.insert( widget_id, - DiskTable::default() + DiskTable::from_config(app_config_fields) .width(width) .height(height) .basic_mode(app_config_fields.use_basic_mode) @@ -620,7 +493,7 @@ pub fn create_layout_tree( /// /// We can do this by just going through the ancestors, starting from the widget itself. pub fn correct_layout_last_selections(arena: &mut Arena, selected: NodeId) { - let mut selected_ancestors = selected.ancestors(&arena).collect::>(); + let mut selected_ancestors = selected.ancestors(arena).collect::>(); let prev_node = selected_ancestors.pop(); if let Some(mut prev_node) = prev_node { for node in selected_ancestors { diff --git a/src/app/process_killer.rs b/src/app/process_killer.rs index 9f38bc5d..c1095850 100644 --- a/src/app/process_killer.rs +++ b/src/app/process_killer.rs @@ -1,78 +1,7 @@ -// Copied from SO: https://stackoverflow.com/a/55231715 -#[cfg(target_os = "windows")] -use winapi::{ - shared::{minwindef::DWORD, ntdef::HANDLE}, - um::{ - processthreadsapi::{OpenProcess, TerminateProcess}, - winnt::{PROCESS_QUERY_INFORMATION, PROCESS_TERMINATE}, - }, -}; +//! This file is meant to house (OS specific) implementations on how to kill processes. + +#[cfg(target_os = "windows")] +pub(crate) mod windows; -/// This file is meant to house (OS specific) implementations on how to kill processes. #[cfg(target_family = "unix")] -use crate::utils::error::BottomError; -use crate::Pid; - -#[cfg(target_os = "windows")] -struct Process(HANDLE); - -#[cfg(target_os = "windows")] -impl Process { - fn open(pid: DWORD) -> Result { - let pc = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_TERMINATE, 0, pid) }; - if pc.is_null() { - return Err("OpenProcess".to_string()); - } - Ok(Process(pc)) - } - - fn kill(self) -> Result<(), String> { - let result = unsafe { TerminateProcess(self.0, 1) }; - if result == 0 { - return Err("Failed to kill process".to_string()); - } - - Ok(()) - } -} - -/// Kills a process, given a PID, for unix. -#[cfg(target_family = "unix")] -pub fn kill_process_given_pid(pid: Pid, signal: usize) -> crate::utils::error::Result<()> { - let output = unsafe { libc::kill(pid as i32, signal as i32) }; - if output != 0 { - // We had an error... - let err_code = std::io::Error::last_os_error().raw_os_error(); - let err = match err_code { - Some(libc::ESRCH) => "the target process did not exist.", - Some(libc::EPERM) => "the calling process does not have the permissions to terminate the target process(es).", - Some(libc::EINVAL) => "an invalid signal was specified.", - _ => "Unknown error occurred." - }; - - return if let Some(err_code) = err_code { - Err(BottomError::GenericError(format!( - "Error code {} - {}", - err_code, err, - ))) - } else { - Err(BottomError::GenericError(format!( - "Error code ??? - {}", - err, - ))) - }; - } - - Ok(()) -} - -/// Kills a process, given a PID, for windows. -#[cfg(target_os = "windows")] -pub fn kill_process_given_pid(pid: Pid) -> crate::utils::error::Result<()> { - { - let process = Process::open(pid as DWORD)?; - process.kill()?; - } - - Ok(()) -} +pub(crate) mod unix; diff --git a/src/app/process_killer/unix.rs b/src/app/process_killer/unix.rs new file mode 100644 index 00000000..46b4b42e --- /dev/null +++ b/src/app/process_killer/unix.rs @@ -0,0 +1,31 @@ +use crate::utils::error::BottomError; +use crate::Pid; + +/// Kills a process, given a PID, for unix. +pub(crate) fn kill_process_given_pid(pid: Pid, signal: usize) -> crate::utils::error::Result<()> { + let output = unsafe { libc::kill(pid as i32, signal as i32) }; + if output != 0 { + // We had an error... + let err_code = std::io::Error::last_os_error().raw_os_error(); + let err = match err_code { + Some(libc::ESRCH) => "the target process did not exist.", + Some(libc::EPERM) => "the calling process does not have the permissions to terminate the target process(es).", + Some(libc::EINVAL) => "an invalid signal was specified.", + _ => "Unknown error occurred." + }; + + return if let Some(err_code) = err_code { + Err(BottomError::GenericError(format!( + "Error code {} - {}", + err_code, err, + ))) + } else { + Err(BottomError::GenericError(format!( + "Error code ??? - {}", + err, + ))) + }; + } + + Ok(()) +} diff --git a/src/app/process_killer/windows.rs b/src/app/process_killer/windows.rs new file mode 100644 index 00000000..84a25a72 --- /dev/null +++ b/src/app/process_killer/windows.rs @@ -0,0 +1,40 @@ +// Copied from SO: https://stackoverflow.com/a/55231715 +use winapi::{ + shared::{minwindef::DWORD, ntdef::HANDLE}, + um::{ + processthreadsapi::{OpenProcess, TerminateProcess}, + winnt::{PROCESS_QUERY_INFORMATION, PROCESS_TERMINATE}, + }, +}; + +pub(crate) struct Process(HANDLE); + +impl Process { + pub(crate) fn open(pid: DWORD) -> Result { + let pc = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_TERMINATE, 0, pid) }; + if pc.is_null() { + return Err("OpenProcess".to_string()); + } + Ok(Process(pc)) + } + + pub(crate) fn kill(self) -> Result<(), String> { + let result = unsafe { TerminateProcess(self.0, 1) }; + if result == 0 { + return Err("Failed to kill process".to_string()); + } + + Ok(()) + } +} + +/// Kills a process, given a PID, for windows. +#[cfg(target_os = "windows")] +pub fn kill_process_given_pid(pid: Pid) -> crate::utils::error::Result<()> { + { + let process = Process::open(pid as DWORD)?; + process.kill()?; + } + + Ok(()) +} diff --git a/src/app/widgets.rs b/src/app/widgets.rs index 7ef2f4dd..dd76f411 100644 --- a/src/app/widgets.rs +++ b/src/app/widgets.rs @@ -138,11 +138,6 @@ pub trait Widget { /// Returns the desired height from the [`Widget`]. fn height(&self) -> LayoutRule; - /// Returns whether this [`Widget`] can be expanded. The default implementation returns `true`. - fn expandable(&self) -> bool { - true - } - /// Returns whether this [`Widget`] can be selected. The default implementation returns [`SelectableType::Selectable`]. fn selectable_type(&self) -> SelectableType { SelectableType::Selectable diff --git a/src/app/widgets/base/sort_menu.rs b/src/app/widgets/base/sort_menu.rs index 548bbf77..1da9b697 100644 --- a/src/app/widgets/base/sort_menu.rs +++ b/src/app/widgets/base/sort_menu.rs @@ -33,6 +33,11 @@ impl SortMenu { } } + pub fn try_show_gap(mut self, show_gap: bool) -> Self { + self.table = self.table.try_show_gap(show_gap); + self + } + /// Updates the index of the [`SortMenu`]. pub fn set_index(&mut self, index: usize) { self.table.scrollable.set_index(index); diff --git a/src/app/widgets/base/sort_text_table.rs b/src/app/widgets/base/sort_text_table.rs index c8ff4d41..2c2d9e21 100644 --- a/src/app/widgets/base/sort_text_table.rs +++ b/src/app/widgets/base/sort_text_table.rs @@ -274,8 +274,8 @@ where st } - pub fn default_ltr(mut self, ltr: bool) -> Self { - self.table = self.table.default_ltr(ltr); + pub fn try_show_gap(mut self, show_gap: bool) -> Self { + self.table = self.table.try_show_gap(show_gap); self } diff --git a/src/app/widgets/base/text_table.rs b/src/app/widgets/base/text_table.rs index e8281b78..d3c9c601 100644 --- a/src/app/widgets/base/text_table.rs +++ b/src/app/widgets/base/text_table.rs @@ -399,7 +399,10 @@ where f.render_widget(block, block_area); return; } - let table_gap = if !self.show_gap || inner_area.height < TABLE_GAP_HEIGHT_LIMIT { + let table_gap = if !self.show_gap + || (data.len() + 2 > inner_area.height.into() + && inner_area.height < TABLE_GAP_HEIGHT_LIMIT) + { 0 } else { 1 diff --git a/src/app/widgets/bottom_widgets/battery.rs b/src/app/widgets/bottom_widgets/battery.rs index 1f9b1a88..3f76ddfc 100644 --- a/src/app/widgets/bottom_widgets/battery.rs +++ b/src/app/widgets/bottom_widgets/battery.rs @@ -185,6 +185,7 @@ impl Widget for BatteryTable { split_area[0].width, split_area[0].height, ); + // FIXME: [URGENT] See if this should be changed; TABLE_GAP_HEIGHT_LIMIT should be removed maybe. May also need to grab the table gap from the config? let data_area = if inner_area.height >= TABLE_GAP_HEIGHT_LIMIT && split_area[1].height > 0 { Rect::new( diff --git a/src/app/widgets/bottom_widgets/cpu.rs b/src/app/widgets/bottom_widgets/cpu.rs index d82f8500..1a312aac 100644 --- a/src/app/widgets/bottom_widgets/cpu.rs +++ b/src/app/widgets/bottom_widgets/cpu.rs @@ -56,7 +56,8 @@ impl CpuGraph { SimpleColumn::new_flex("CPU".into(), 0.5), SimpleColumn::new_hard("Use".into(), None), ]) - .default_ltr(false); + .default_ltr(false) + .try_show_gap(app_config_fields.table_gap); let legend_position = if app_config_fields.left_legend { CpuGraphLegendPosition::Left } else { diff --git a/src/app/widgets/bottom_widgets/disk.rs b/src/app/widgets/bottom_widgets/disk.rs index c3ec8bcd..e23b2f4b 100644 --- a/src/app/widgets/bottom_widgets/disk.rs +++ b/src/app/widgets/bottom_widgets/disk.rs @@ -4,8 +4,8 @@ use tui::{backend::Backend, layout::Rect, widgets::Borders, Frame}; use crate::{ app::{ data_farmer::DataCollection, event::ComponentEventResult, - sort_text_table::SimpleSortableColumn, text_table::TextTableData, Component, TextTable, - Widget, + sort_text_table::SimpleSortableColumn, text_table::TextTableData, AppConfigFields, + Component, TextTable, Widget, }, canvas::Painter, data_conversion::convert_disk_row, @@ -25,8 +25,9 @@ pub struct DiskTable { show_scroll_index: bool, } -impl Default for DiskTable { - fn default() -> Self { +impl DiskTable { + /// Creates a [`DiskTable`] from a config. + pub fn from_config(app_config_fields: &AppConfigFields) -> Self { let table = TextTable::new(vec![ SimpleSortableColumn::new_flex("Disk".into(), None, false, 0.2), SimpleSortableColumn::new_flex("Mount".into(), None, false, 0.2), @@ -35,7 +36,8 @@ impl Default for DiskTable { SimpleSortableColumn::new_hard("Total".into(), None, false, Some(6)), SimpleSortableColumn::new_hard("R/s".into(), None, false, Some(7)), SimpleSortableColumn::new_hard("W/s".into(), None, false, Some(7)), - ]); + ]) + .try_show_gap(app_config_fields.table_gap); Self { table, @@ -47,9 +49,7 @@ impl Default for DiskTable { show_scroll_index: false, } } -} -impl DiskTable { /// Sets the width. pub fn width(mut self, width: LayoutRule) -> Self { self.width = width; diff --git a/src/app/widgets/bottom_widgets/net.rs b/src/app/widgets/bottom_widgets/net.rs index 4be88045..1f13380d 100644 --- a/src/app/widgets/bottom_widgets/net.rs +++ b/src/app/widgets/bottom_widgets/net.rs @@ -599,6 +599,7 @@ impl OldNetGraph { SimpleColumn::new_flex("Total RX".into(), 0.25), SimpleColumn::new_flex("Total TX".into(), 0.25), ]) + .try_show_gap(config.table_gap) .unselectable(), bounds: Rect::default(), width: LayoutRule::default(), @@ -646,11 +647,14 @@ impl Widget for OldNetGraph { &mut self, painter: &Painter, f: &mut Frame<'_, B>, area: Rect, selected: bool, expanded: bool, ) { - const CONSTRAINTS: [Constraint; 2] = [Constraint::Min(0), Constraint::Length(4)]; + let constraints = [ + Constraint::Min(0), + Constraint::Length(if self.table.show_gap { 5 } else { 4 }), + ]; let split_area = Layout::default() .direction(Direction::Vertical) - .constraints(CONSTRAINTS) + .constraints(constraints) .split(area); let graph_area = split_area[0]; diff --git a/src/app/widgets/bottom_widgets/process.rs b/src/app/widgets/bottom_widgets/process.rs index 074370b3..8ca3a02e 100644 --- a/src/app/widgets/bottom_widgets/process.rs +++ b/src/app/widgets/bottom_widgets/process.rs @@ -20,7 +20,7 @@ use crate::{ query::*, text_table::DesiredColumnWidth, widgets::tui_stuff::BlockBuilder, - DataCollection, + AppConfigFields, DataCollection, }, canvas::Painter, data_conversion::get_string_with_bytes, @@ -273,7 +273,7 @@ pub struct ProcessManager { impl ProcessManager { /// Creates a new [`ProcessManager`]. - pub fn new(process_defaults: &ProcessDefaults) -> Self { + pub fn new(process_defaults: &ProcessDefaults, config: &AppConfigFields) -> Self { let process_table_columns = vec![ ProcessSortColumn::new(ProcessSortType::Pid), ProcessSortColumn::new(ProcessSortType::Name), @@ -290,8 +290,10 @@ impl ProcessManager { let mut manager = Self { bounds: Rect::default(), - sort_menu: SortMenu::new(process_table_columns.len()), - process_table: SortableTextTable::new(process_table_columns).default_sort_index(2), + sort_menu: SortMenu::new(process_table_columns.len()).try_show_gap(config.table_gap), + process_table: SortableTextTable::new(process_table_columns) + .default_sort_index(2) + .try_show_gap(config.table_gap), search_input: TextInput::default(), search_block_bounds: Rect::default(), dd_multi: MultiKey::register(vec!['d', 'd']), // TODO: [Optimization] Maybe use something static/const/arrayvec?... @@ -494,6 +496,12 @@ impl ProcessManager { self.search_modifiers.toggle_regex(); ComponentEventResult::Signal(ReturnSignal::Update) } + + /// Toggles tree mode. + fn toggle_tree_mode(&mut self) -> ComponentEventResult { + self.in_tree_mode = !self.in_tree_mode; + ComponentEventResult::Signal(ReturnSignal::Update) + } } impl Component for ProcessManager { @@ -567,8 +575,7 @@ impl Component for ProcessManager { // Collapse a branch } KeyCode::Char('t') | KeyCode::F(5) => { - self.in_tree_mode = !self.in_tree_mode; - return ComponentEventResult::Redraw; + return self.toggle_tree_mode(); } KeyCode::Char('s') | KeyCode::F(6) => { return self.open_sort(); diff --git a/src/app/widgets/bottom_widgets/temp.rs b/src/app/widgets/bottom_widgets/temp.rs index 70faf862..c6dfcff7 100644 --- a/src/app/widgets/bottom_widgets/temp.rs +++ b/src/app/widgets/bottom_widgets/temp.rs @@ -5,7 +5,7 @@ use crate::{ app::{ data_farmer::DataCollection, data_harvester::temperature::TemperatureType, event::ComponentEventResult, sort_text_table::SimpleSortableColumn, - text_table::TextTableData, Component, TextTable, Widget, + text_table::TextTableData, AppConfigFields, Component, TextTable, Widget, }, canvas::Painter, data_conversion::convert_temp_row, @@ -24,13 +24,15 @@ pub struct TempTable { show_scroll_index: bool, } -impl Default for TempTable { - fn default() -> Self { +impl TempTable { + /// Creates a [`TempTable`] from a config. + pub fn from_config(app_config_fields: &AppConfigFields) -> Self { let table = TextTable::new(vec![ SimpleSortableColumn::new_flex("Sensor".into(), None, false, 0.8), SimpleSortableColumn::new_hard("Temp".into(), None, false, Some(5)), ]) - .default_ltr(false); + .default_ltr(false) + .try_show_gap(app_config_fields.table_gap); Self { table, @@ -43,9 +45,7 @@ impl Default for TempTable { show_scroll_index: false, } } -} -impl TempTable { /// Sets the [`TemperatureType`] for the [`TempTable`]. pub fn set_temp_type(mut self, temp_type: TemperatureType) -> Self { self.temp_type = temp_type; diff --git a/src/canvas.rs b/src/canvas.rs index d95c6c4b..a2ba4ffd 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, str::FromStr}; +use std::str::FromStr; use fxhash::FxHashMap; use indextree::{Arena, NodeId}; @@ -17,47 +17,18 @@ use crate::{ app::{ self, layout_manager::{generate_layout, ColLayout, LayoutNode, RowLayout}, - text_table::TextTableData, widgets::{Component, Widget}, DialogState, TmpBottomWidget, }, constants::*, - data_conversion::{ConvertedBatteryData, ConvertedCpuData, ConvertedProcessData}, options::Config, utils::error, utils::error::BottomError, - Pid, }; mod canvas_colours; mod dialogs; -/// Point is of time, data -type Point = (f64, f64); - -#[derive(Default)] -pub struct DisplayableData { - pub rx_display: String, - pub tx_display: String, - pub total_rx_display: String, - pub total_tx_display: String, - pub network_data_rx: Vec, - pub network_data_tx: Vec, - pub disk_data: TextTableData, - pub temp_sensor_data: TextTableData, - pub single_process_data: HashMap, // Contains single process data, key is PID - pub stringified_process_data_map: HashMap)>, 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, - pub swap_data: Vec, - - pub load_avg_data: [f32; 3], - pub cpu_data: Vec, - pub battery_data: Vec, -} - #[derive(Debug)] pub enum ColourScheme { Default, @@ -208,7 +179,7 @@ impl Painter { }) .split(vertical_dialog_chunk[1]); - help_dialog.draw_help(&self, f, middle_dialog_chunk[1]); + help_dialog.draw_help(self, f, middle_dialog_chunk[1]); } else if app_state.delete_dialog_state.is_showing_dd { // TODO: [Drawing] Better dd sizing needs the paragraph wrap feature from tui-rs to be pushed to // complete... but for now it's pretty close! @@ -306,8 +277,7 @@ impl Painter { fn traverse_and_draw_tree( node: NodeId, arena: &Arena, f: &mut Frame<'_, B>, lookup_map: &mut FxHashMap, painter: &Painter, - canvas_data: &DisplayableData, selected_id: NodeId, offset_x: u16, - offset_y: u16, + selected_id: NodeId, offset_x: u16, offset_y: u16, ) { if let Some(layout_node) = arena.get(node).map(|n| n.get()) { match layout_node { @@ -320,7 +290,6 @@ impl Painter { f, lookup_map, painter, - canvas_data, selected_id, offset_x + bound.x, offset_y + bound.y, @@ -371,23 +340,12 @@ impl Painter { let root = &app_state.layout_tree_root; let arena = &mut app_state.layout_tree; - let canvas_data = &app_state.canvas_data; let selected_id = app_state.selected_widget; generate_layout(*root, arena, draw_area, &app_state.widget_lookup_map); let lookup_map = &mut app_state.widget_lookup_map; - traverse_and_draw_tree( - *root, - arena, - f, - lookup_map, - self, - canvas_data, - selected_id, - 0, - 0, - ); + traverse_and_draw_tree(*root, arena, f, lookup_map, self, selected_id, 0, 0); } })?; diff --git a/src/constants.rs b/src/constants.rs index 103ed626..7fc6ce21 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -10,10 +10,8 @@ pub const STALE_MIN_MILLISECONDS: u64 = 30 * 1000; // Lowest is 30 seconds pub const TIME_CHANGE_MILLISECONDS: u64 = 15 * 1000; // How much to increment each time pub const AUTOHIDE_TIMEOUT_MILLISECONDS: u64 = 5000; // 5 seconds to autohide -pub const TICK_RATE_IN_MILLISECONDS: u64 = 200; // How fast the screen refreshes pub const DEFAULT_REFRESH_RATE_IN_MILLISECONDS: u64 = 1000; -pub const MAX_KEY_TIMEOUT_IN_MILLISECONDS: u64 = 1000; // Limits for when we should stop showing table gaps/labels (anything less means not shown) pub const TABLE_GAP_HEIGHT_LIMIT: u16 = 5; @@ -30,14 +28,6 @@ pub const MAX_SIGNAL: usize = 31; // Side borders pub static SIDE_BORDERS: Lazy = Lazy::new(|| tui::widgets::Borders::from_bits_truncate(20)); -pub static TOP_LEFT_RIGHT: Lazy = - Lazy::new(|| tui::widgets::Borders::from_bits_truncate(22)); -pub static BOTTOM_LEFT_RIGHT: Lazy = - Lazy::new(|| tui::widgets::Borders::from_bits_truncate(28)); -pub static DEFAULT_TEXT_STYLE: Lazy = - Lazy::new(|| tui::style::Style::default().fg(tui::style::Color::Gray)); -pub static DEFAULT_HEADER_STYLE: Lazy = - Lazy::new(|| tui::style::Style::default().fg(tui::style::Color::LightBlue)); // Colour profiles pub static DEFAULT_LIGHT_MODE_COLOUR_PALETTE: Lazy = Lazy::new(|| ConfigColours { diff --git a/src/data_conversion.rs b/src/data_conversion.rs index e32982b2..4ca3ae9c 100644 --- a/src/data_conversion.rs +++ b/src/data_conversion.rs @@ -546,16 +546,6 @@ pub fn convert_network_data_points( } } -pub enum ProcessGroupingType { - Grouped, - Ungrouped, -} - -pub enum ProcessNamingType { - Name, - Path, -} - /// Given read/s, write/s, total read, and total write values, return 4 strings that represent read/s, write/s, total read, and total write pub fn get_disk_io_strings( rps: u64, wps: u64, total_read: u64, total_write: u64, @@ -579,142 +569,15 @@ pub fn get_string_with_bytes(value: u64) -> String { } } -/// Because we needed to UPDATE data entries rather than REPLACING entries, we instead update -/// the existing vector. -pub fn convert_process_data( - current_data: &DataCollection, - existing_converted_process_data: &mut HashMap, - #[cfg(target_family = "unix")] user_table: &mut data_harvester::processes::UserTable, -) { - // TODO: [Feature] Thread highlighting and hiding support; can we also count number of threads per process and display it as a column? - // For macOS see https://github.com/hishamhm/htop/pull/848/files - - let mut complete_pid_set: fxhash::FxHashSet = - existing_converted_process_data.keys().copied().collect(); - - for process in ¤t_data.process_harvest { - let (read_per_sec, write_per_sec, total_read, total_write) = get_disk_io_strings( - process.read_bytes_per_sec, - process.write_bytes_per_sec, - process.total_read_bytes, - process.total_write_bytes, - ); - - let mem_usage_str = get_binary_bytes(process.mem_usage_bytes); - - let user = { - #[cfg(target_family = "unix")] - { - user_table.get_uid_to_username_mapping(process.uid).ok() - } - #[cfg(not(target_family = "unix"))] - { - None - } - }; - - if let Some(process_entry) = existing_converted_process_data.get_mut(&process.pid) { - complete_pid_set.remove(&process.pid); - - // Very dumb way to see if there's PID reuse... - if process_entry.ppid == process.parent_pid { - process_entry.name = process.name.to_string(); - process_entry.command = process.command.to_string(); - process_entry.cpu_percent_usage = process.cpu_usage_percent; - process_entry.mem_percent_usage = process.mem_usage_percent; - process_entry.mem_usage_bytes = process.mem_usage_bytes; - process_entry.mem_usage_str = mem_usage_str; - process_entry.group_pids = vec![process.pid]; - process_entry.read_per_sec = read_per_sec; - process_entry.write_per_sec = write_per_sec; - process_entry.total_read = total_read; - process_entry.total_write = total_write; - process_entry.rps_f64 = process.read_bytes_per_sec as f64; - process_entry.wps_f64 = process.write_bytes_per_sec as f64; - process_entry.tr_f64 = process.total_read_bytes as f64; - process_entry.tw_f64 = process.total_write_bytes as f64; - process_entry.process_state = process.process_state.to_owned(); - process_entry.process_char = process.process_state_char; - process_entry.process_description_prefix = None; - process_entry.is_disabled_entry = false; - process_entry.user = user; - } else { - // ...I hate that I can't combine if let and an if statement in one line... - *process_entry = ConvertedProcessData { - pid: process.pid, - ppid: process.parent_pid, - is_thread: None, - name: process.name.to_string(), - command: process.command.to_string(), - cpu_percent_usage: process.cpu_usage_percent, - mem_percent_usage: process.mem_usage_percent, - mem_usage_bytes: process.mem_usage_bytes, - mem_usage_str, - group_pids: vec![process.pid], - read_per_sec, - write_per_sec, - total_read, - total_write, - rps_f64: process.read_bytes_per_sec as f64, - wps_f64: process.write_bytes_per_sec as f64, - tr_f64: process.total_read_bytes as f64, - tw_f64: process.total_write_bytes as f64, - process_state: process.process_state.to_owned(), - process_char: process.process_state_char, - process_description_prefix: None, - is_disabled_entry: false, - is_collapsed_entry: false, - user, - }; - } - } else { - existing_converted_process_data.insert( - process.pid, - ConvertedProcessData { - pid: process.pid, - ppid: process.parent_pid, - is_thread: None, - name: process.name.to_string(), - command: process.command.to_string(), - cpu_percent_usage: process.cpu_usage_percent, - mem_percent_usage: process.mem_usage_percent, - mem_usage_bytes: process.mem_usage_bytes, - mem_usage_str, - group_pids: vec![process.pid], - read_per_sec, - write_per_sec, - total_read, - total_write, - rps_f64: process.read_bytes_per_sec as f64, - wps_f64: process.write_bytes_per_sec as f64, - tr_f64: process.total_read_bytes as f64, - tw_f64: process.total_write_bytes as f64, - process_state: process.process_state.to_owned(), - process_char: process.process_state_char, - process_description_prefix: None, - is_disabled_entry: false, - is_collapsed_entry: false, - user, - }, - ); - } - } - - // Now clean up any spare entries that weren't visited, to avoid clutter: - complete_pid_set.iter().for_each(|pid| { - existing_converted_process_data.remove(pid); - }) -} - -const BRANCH_ENDING: char = '└'; -const BRANCH_VERTICAL: char = '│'; -const BRANCH_SPLIT: char = '├'; -const BRANCH_HORIZONTAL: char = '─'; - fn tree_process_data( filtered_process_data: &[ConvertedProcessData], is_using_command: bool, sorting_type: &ProcessSorting, is_sort_descending: bool, ) -> Vec { + const BRANCH_ENDING: char = '└'; + const BRANCH_VERTICAL: char = '│'; + const BRANCH_SPLIT: char = '├'; + const BRANCH_HORIZONTAL: char = '─'; + // TODO: [Feature] Option to sort usage by total branch usage or individual value usage? // Let's first build up a (really terrible) parent -> child mapping... @@ -1178,166 +1041,6 @@ fn tree_process_data( .collect::>() } -// FIXME: [URGENT] Delete this -// // TODO: [Optimization] This is an easy target for optimization, too many to_strings! -// fn stringify_process_data( -// proc_widget_state: &ProcWidgetState, finalized_process_data: &[ConvertedProcessData], -// ) -> Vec<(Vec<(String, Option)>, bool)> { -// let is_proc_widget_grouped = proc_widget_state.is_grouped; -// let is_using_command = proc_widget_state.is_using_command; -// let is_tree = proc_widget_state.is_tree_mode; -// let mem_enabled = proc_widget_state.columns.is_enabled(&ProcessSorting::Mem); - -// finalized_process_data -// .iter() -// .map(|process| { -// ( -// vec![ -// ( -// if is_proc_widget_grouped { -// process.group_pids.len().to_string() -// } else { -// process.pid.to_string() -// }, -// None, -// ), -// ( -// if is_tree { -// if let Some(prefix) = &process.process_description_prefix { -// prefix.clone() -// } else { -// String::default() -// } -// } else if is_using_command { -// process.command.clone() -// } else { -// process.name.clone() -// }, -// None, -// ), -// (format!("{:.1}%", process.cpu_percent_usage), None), -// ( -// if mem_enabled { -// if process.mem_usage_bytes <= GIBI_LIMIT { -// format!("{:.0}{}", process.mem_usage_str.0, process.mem_usage_str.1) -// } else { -// format!("{:.1}{}", process.mem_usage_str.0, process.mem_usage_str.1) -// } -// } else { -// format!("{:.1}%", process.mem_percent_usage) -// }, -// None, -// ), -// (process.read_per_sec.clone(), None), -// (process.write_per_sec.clone(), None), -// (process.total_read.clone(), None), -// (process.total_write.clone(), None), -// #[cfg(target_family = "unix")] -// ( -// if let Some(user) = &process.user { -// user.clone() -// } else { -// "N/A".to_string() -// }, -// None, -// ), -// ( -// process.process_state.clone(), -// Some(process.process_char.to_string()), -// ), -// ], -// process.is_disabled_entry, -// ) -// }) -// .collect() -// } - -/// Takes a set of converted process data and groups it together. -/// -/// To be honest, I really don't like how this is done, even though I've rewritten this like 3 times. -fn group_process_data( - single_process_data: &[ConvertedProcessData], is_using_command: bool, -) -> Vec { - #[derive(Clone, Default, Debug)] - struct SingleProcessData { - pub pid: Pid, - pub cpu_percent_usage: f64, - pub mem_percent_usage: f64, - pub mem_usage_bytes: u64, - pub group_pids: Vec, - pub read_per_sec: f64, - pub write_per_sec: f64, - pub total_read: f64, - pub total_write: f64, - pub process_state: String, - } - - let mut grouped_hashmap: HashMap = std::collections::HashMap::new(); - - single_process_data.iter().for_each(|process| { - let entry = grouped_hashmap - .entry(if is_using_command { - process.command.to_string() - } else { - process.name.to_string() - }) - .or_insert(SingleProcessData { - pid: process.pid, - ..SingleProcessData::default() - }); - - (*entry).cpu_percent_usage += process.cpu_percent_usage; - (*entry).mem_percent_usage += process.mem_percent_usage; - (*entry).mem_usage_bytes += process.mem_usage_bytes; - (*entry).group_pids.push(process.pid); - (*entry).read_per_sec += process.rps_f64; - (*entry).write_per_sec += process.wps_f64; - (*entry).total_read += process.tr_f64; - (*entry).total_write += process.tw_f64; - }); - - grouped_hashmap - .iter() - .map(|(identifier, process_details)| { - let p = process_details.clone(); - - let (read_per_sec, write_per_sec, total_read, total_write) = get_disk_io_strings( - p.read_per_sec as u64, - p.write_per_sec as u64, - p.total_read as u64, - p.total_write as u64, - ); - - ConvertedProcessData { - pid: p.pid, - ppid: None, - is_thread: None, - name: identifier.to_string(), - command: identifier.to_string(), - cpu_percent_usage: p.cpu_percent_usage, - mem_percent_usage: p.mem_percent_usage, - mem_usage_bytes: p.mem_usage_bytes, - mem_usage_str: get_decimal_bytes(p.mem_usage_bytes), - group_pids: p.group_pids, - read_per_sec, - write_per_sec, - total_read, - total_write, - rps_f64: p.read_per_sec, - wps_f64: p.write_per_sec, - tr_f64: p.total_read, - tw_f64: p.total_write, - process_state: p.process_state, - process_description_prefix: None, - process_char: char::default(), - is_disabled_entry: false, - is_collapsed_entry: false, - user: None, - } - }) - .collect::>() -} - #[cfg(feature = "battery")] pub fn convert_battery_harvest(current_data: &DataCollection) -> Vec { current_data diff --git a/src/lib.rs b/src/lib.rs index b079e369..7a82a9b2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,7 +42,7 @@ pub mod clap; pub mod constants; pub mod data_conversion; pub mod options; -pub mod units; +pub(crate) mod units; #[cfg(target_family = "windows")] pub type Pid = usize; diff --git a/src/options.rs b/src/options.rs index bd257ff4..545f595d 100644 --- a/src/options.rs +++ b/src/options.rs @@ -211,11 +211,7 @@ pub fn build_app(matches: &clap::ArgMatches<'static>, config: &mut Config) -> Re hide_time: get_hide_time(matches, config), autohide_time, use_old_network_legend: get_use_old_network_legend(matches, config), - table_gap: if get_hide_table_gap(matches, config) { - 0 - } else { - 1 - }, + table_gap: !get_hide_table_gap(matches, config), disable_click: get_disable_click(matches, config), // no_write: get_no_write(matches, config), no_write: false, @@ -551,18 +547,6 @@ fn get_use_battery(matches: &clap::ArgMatches<'static>, config: &Config) -> bool false } -#[allow(dead_code)] -fn get_no_write(matches: &clap::ArgMatches<'static>, config: &Config) -> bool { - if matches.is_present("no_write") { - return true; - } else if let Some(flags) = &config.flags { - if let Some(no_write) = flags.no_write { - return no_write; - } - } - false -} - fn get_ignore_list(ignore_list: &Option) -> error::Result> { if let Some(ignore_list) = ignore_list { let list: Result, _> = ignore_list diff --git a/src/utils/error.rs b/src/utils/error.rs index cd41a44e..b65ce229 100644 --- a/src/utils/error.rs +++ b/src/utils/error.rs @@ -31,9 +31,6 @@ pub enum BottomError { /// An error to represent errors with querying. #[error("Query error, {0}")] QueryError(Cow<'static, str>), - /// An error that just signifies something minor went wrong; no message. - #[error("Minor error.")] - MinorError, /// An error to represent errors with procfs #[cfg(target_os = "linux")] #[error("Procfs error, {0}")] diff --git a/src/utils/gen_util.rs b/src/utils/gen_util.rs index e0265290..2542d3ac 100644 --- a/src/utils/gen_util.rs +++ b/src/utils/gen_util.rs @@ -7,7 +7,6 @@ pub const TERA_LIMIT: u64 = 1_000_000_000_000; pub const KIBI_LIMIT: u64 = 1024; pub const MEBI_LIMIT: u64 = 1_048_576; pub const GIBI_LIMIT: u64 = 1_073_741_824; -pub const TEBI_LIMIT: u64 = 1_099_511_627_776; pub const KILO_LIMIT_F64: f64 = 1000.0; pub const MEGA_LIMIT_F64: f64 = 1_000_000.0; @@ -30,16 +29,6 @@ pub const LOG_GIBI_LIMIT: f64 = 30.0; pub const LOG_TEBI_LIMIT: f64 = 40.0; pub const LOG_PEBI_LIMIT: f64 = 50.0; -pub const LOG_KILO_LIMIT_U32: u32 = 3; -pub const LOG_MEGA_LIMIT_U32: u32 = 6; -pub const LOG_GIGA_LIMIT_U32: u32 = 9; -pub const LOG_TERA_LIMIT_U32: u32 = 12; - -pub const LOG_KIBI_LIMIT_U32: u32 = 10; -pub const LOG_MEBI_LIMIT_U32: u32 = 20; -pub const LOG_GIBI_LIMIT_U32: u32 = 30; -pub const LOG_TEBI_LIMIT_U32: u32 = 40; - /// Returns a tuple containing the value and the unit in bytes. In units of 1024. /// This only supports up to a tebi. Note the "single" unit will have a space appended to match the others if /// `spacing` is true.