diff options
author | cyqsimon <28627918+cyqsimon@users.noreply.github.com> | 2023-10-19 16:55:04 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-10-19 16:55:04 +0800 |
commit | 0c4987aa86dc5210c11024b74f3f8947ba1358ac (patch) | |
tree | d133948be851cb4209a006237dd99fda25d1909c /src | |
parent | d9cc84b3732fe9cb0c819b93d67d2a6420f6a065 (diff) |
Table formatting logic overhaul (#305)
* Table formatting logic overhaul
- Columns now auto-expand and auto-shrink proportionally
- Data column selection logic is now set per-table
- Necessary boilerplate added to allow tables with more (or fewer) columns in the future
* Better naming: `TableLayout` -> `DisplayLayout`
* Fix clippy complaints
* Optimise layout cutoff widths
- These values are pretty much arbitrary. I'm open to further optimising them in the future.
* Updated test snapshots to match new layout settings
* Remove unnecessary logging
* Correct `debug_fn` impl for `column_selector`
* Further optimise bandwidth column display
* Update test snapshots
* Layout width preset minor adjustment
Diffstat (limited to 'src')
39 files changed, 623 insertions, 514 deletions
diff --git a/src/display/components/display_bandwidth.rs b/src/display/components/display_bandwidth.rs index d34ffe1..4c7e010 100644 --- a/src/display/components/display_bandwidth.rs +++ b/src/display/components/display_bandwidth.rs @@ -2,25 +2,23 @@ use std::fmt; pub struct DisplayBandwidth { pub bandwidth: f64, - pub as_rate: bool, } impl fmt::Display for DisplayBandwidth { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let suffix = if self.as_rate { "ps" } else { "" }; - if self.bandwidth > 999_999_999_999.0 { - // 1024 * 1024 * 1024 * 1024 - write!(f, "{:.2}TiB{suffix}", self.bandwidth / 1_099_511_627_776.0,) - } else if self.bandwidth > 999_999_999.0 { - // 1024 * 1024 * 1024 - write!(f, "{:.2}GiB{suffix}", self.bandwidth / 1_073_741_824.0) - } else if self.bandwidth > 999_999.0 { - // 1024 * 1024 - write!(f, "{:.2}MiB{suffix}", self.bandwidth / 1_048_576.0) - } else if self.bandwidth > 999.0 { - write!(f, "{:.2}KiB{suffix}", self.bandwidth / 1024.0) + // see https://github.com/rust-lang/rust/issues/41620 + let (div, suffix) = if self.bandwidth >= 1e12 { + (1_099_511_627_776.0, "TiB") + } else if self.bandwidth >= 1e9 { + (1_073_741_824.0, "GiB") + } else if self.bandwidth >= 1e6 { + (1_048_576.0, "MiB") + } else if self.bandwidth >= 1e3 { + (1024.0, "KiB") } else { - write!(f, "{}B{suffix}", self.bandwidth) - } + (1.0, "B") + }; + + write!(f, "{:.2}{suffix}", self.bandwidth / div) } } diff --git a/src/display/components/header_details.rs b/src/display/components/header_details.rs index e997406..3f68984 100644 --- a/src/display/components/header_details.rs +++ b/src/display/components/header_details.rs @@ -80,19 +80,19 @@ impl<'a> HeaderDetails<'a> { } fn bandwidth_string(&self) -> String { - let c_mode = self.state.cumulative_mode; - format!( - " Total Up / Down: {} / {}{}", - DisplayBandwidth { - bandwidth: self.state.total_bytes_uploaded as f64, - as_rate: !c_mode, - }, - DisplayBandwidth { - bandwidth: self.state.total_bytes_downloaded as f64, - as_rate: !c_mode, - }, - if self.paused { " [PAUSED]" } else { "" } - ) + let t = if self.state.cumulative_mode { + "Data" + } else { + "Rate" + }; + let up = DisplayBandwidth { + bandwidth: self.state.total_bytes_uploaded as f64, + }; + let down = DisplayBandwidth { + bandwidth: self.state.total_bytes_downloaded as f64, + }; + let paused = if self.paused { " [PAUSED]" } else { "" }; + format!(" Total {t} (Up / Down): {up} / {down}{paused}") } fn render_elapsed_time( diff --git a/src/display/components/layout.rs b/src/display/components/layout.rs index 928be3d..60b7f23 100644 --- a/src/display/components/layout.rs +++ b/src/display/components/layout.rs @@ -27,7 +27,7 @@ fn top_app_and_bottom_split(rect: Rect) -> (Rect, Rect, Rect) { pub struct Layout<'a> { pub header: HeaderDetails<'a>, - pub children: Vec<Table<'a>>, + pub children: Vec<Table>, pub footer: HelpText, } diff --git a/src/display/components/table.rs b/src/display/components/table.rs index 70e1b51..ed97349 100644 --- a/src/display/components/table.rs +++ b/src/display/components/table.rs @@ -1,9 +1,7 @@ -use std::{ - collections::{BTreeMap, HashMap}, - iter::FromIterator, - net::IpAddr, -}; +use std::{collections::HashMap, fmt, iter::FromIterator, net::IpAddr, ops::Index, rc::Rc}; +use derivative::Derivative; +use itertools::Itertools; use ratatui::{ backend::Backend, layout::{Constraint, Rect}, @@ -11,95 +9,195 @@ use ratatui::{ terminal::Frame, widgets::{Block, Borders, Row}, }; -use unicode_width::UnicodeWidthChar; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::{ display::{Bandwidth, DisplayBandwidth, UIState}, network::{display_connection_string, display_ip_or_host}, }; -fn display_upload_and_download(bandwidth: &impl Bandwidth, total: bool) -> String { - format!( - "{} / {}", - DisplayBandwidth { - bandwidth: bandwidth.get_total_bytes_uploaded() as f64, - as_rate: !total, - }, - DisplayBandwidth { - bandwidth: bandwidth.get_total_bytes_downloaded() as f64, - as_rate: !total, - }, - ) -} - -pub enum ColumnCount { - Two, - Three, +/// The displayed layout choice of a table. +/// Each value in the array is the width of each column. +/// +/// Note that this only determines how a table is displayed, not what data it contains. +/// +/// If we intend to display different number of columns in the future, +/// then new variants should be added. +#[derive(Copy, Clone, Debug)] +pub enum DisplayLayout { + /// Show 2 columns. + C2([u16; 2]), + /// Show 3 columns. + C3([u16; 3]), } +impl Index<usize> for DisplayLayout { + type Output = u16; -impl ColumnCount { - pub fn as_u16(&self) -> u16 { - match &self { - ColumnCount::Two => 2, - ColumnCount::Three => 3, + fn index(&self, i: usize) -> &Self::Output { + match self { + Self::C2(arr) => &arr[i], + Self::C3(arr) => &arr[i], } } } +impl DisplayLayout { + #[inline] + fn columns_count(&self) -> usize { + match self { + Self::C2(_) => 2, + Self::C3(_) => 3, + } + } + #[inline] + fn iter(&self) -> impl Iterator<Item = &u16> { + match self { + Self::C2(ws) => ws.iter(), + Self::C3(ws) => ws.iter(), + } + } + #[inline] + fn widths_sum(&self) -> u16 { + self.iter().sum() + } + /// Returns the computed actual width and the spacer width. + /// + /// See [`Table`] for layout rules. + fn compute_actual_widths(&self, available: u16) -> (Self, u16) { + let columns_count = self.columns_count() as u16; + let desired_min = self.widths_sum(); -pub struct ColumnData { - column_count: ColumnCount, - column_widths: Vec<u16>, -} + // spacer max width is 2 + let spacer = if available > desired_min { + ((available - desired_min) / (columns_count - 1)).min(2) + } else { + 0 + }; + let available_without_spacers = available - spacer * (columns_count - 1); -pub struct Table<'a> { - title: &'a str, - column_names: &'a [&'a str], - rows: Vec<Vec<String>>, - breakpoints: BTreeMap<u16, ColumnData>, -} + // multiplier + let m = available_without_spacers as f64 / desired_min as f64; -fn truncate_iter_to_unicode_width<Input, Collect>(iter: Input, width: usize) -> Collect -where - Input: Iterator<Item = char>, - Collect: FromIterator<char>, -{ - let mut chunk_width = 0; - iter.take_while(|ch| { - chunk_width += ch.width().unwrap_or(0); - chunk_width <= width - }) - .collect() + // remainder width is arbitrarily given to column 0 + let computed = match *self { + Self::C2([_w0, w1]) => { + let w1_new = (w1 as f64 * m).trunc() as u16; + Self::C2([available_without_spacers - w1_new, w1_new]) + } + Self::C3([_w0, w1, w2]) => { + let w1_new = (w1 as f64 * m).trunc() as u16; + let w2_new = (w2 as f64 * m).trunc() as u16; + Self::C3([available_without_spacers - w1_new - w2_new, w1_new, w2_new]) + } + }; + + (computed, spacer) + } } -fn truncate_middle(row: &str, max_length: u16) -> String { - if max_length < 6 { - truncate_iter_to_unicode_width(row.chars(), max_length as usize) - } else if row.len() as u16 > max_length { - let split_point = (max_length as usize / 2) - 3; - // why 3? 5 is the max size of the truncation text ([...] or [..]), 3 is ~5/2 - let first_slice = truncate_iter_to_unicode_width::<_, String>(row.chars(), split_point); - let second_slice = - truncate_iter_to_unicode_width::<_, Vec<_>>(row.chars().rev(), split_point) - .into_iter() - .rev() - .collect::<String>(); - if max_length % 2 == 0 { - format!("{first_slice}[...]{second_slice}") - } else { - format!("{first_slice}[..]{second_slice}") +/// All data of a table. +/// +/// If tables with different number of columns are added in the future, +/// then new variants should be added. +#[derive(Clone, Debug)] +enum TableData { + /// A table with 3 columns. + C3(NColsTableData<3>), +} +impl From<NColsTableData<3>> for TableData { + fn from(data: NColsTableData<3>) -> Self { + Self::C3(data) + } +} +impl TableData { + fn column_names(&self) -> &[&str] { + match self { + Self::C3(inner) => &inner.column_names, + } + } + fn rows(&self) -> Vec<&[String]> { + match self { + Self::C3(inner) => inner.rows.iter().map(|r| r.as_slice()).collect(), } - } else { - row.to_string() } + fn column_selector(&self) -> &dyn Fn(&DisplayLayout) -> Vec<usize> { + match self { + Self::C3(inner) => inner.column_selector.as_ref(), + } + } +} + +/// All data of a table with `C` columns. +/// +/// Note that the number of columns here is independent of the number of columns +/// being actually shown. If width-constrained, we might only show some of the columns. +#[derive(Clone, Derivative)] +#[derivative(Debug)] +struct NColsTableData<const C: usize> { + /// The name of each column. + column_names: [&'static str; C], + /// All rows of data. + rows: Vec<[String; C]>, + /// Function to determine which columns to show for a given layout. + /// + /// This function should return a vector of column indices. + /// The indices should be less than `C`; otherwise this will cause a runtime panic. + #[derivative(Debug(format_with = "debug_fn::<C>"))] + column_selector: Rc<ColumnSelectorFn>, +} + +/// Clippy wanted me to write this. 💢 +type ColumnSelectorFn = dyn Fn(&DisplayLayout) -> Vec<usize>; + +fn debug_fn<const C: usize>( + _func: &Rc<ColumnSelectorFn>, + f: &mut fmt::Formatter, +) -> Result<(), fmt::Error> { + write!(f, "Rc</* function pointer */>") } -impl<'a> Table<'a> { +/// A table displayed by bandwhich. +#[derive(Clone, Debug)] +pub struct Table { + title: &'static str, + /// A layout mapping between minimum available width and the width of each column. + /// + /// Note that the width of each column here is the "desired minimum width". + /// + /// - Wt = available width of table + /// - Wd = sum of desired minimum width of each column + /// + /// - If `Wt >= Wd`, spacers with a maximum width of `2` will be inserted + /// between columns; and then the columns will proportionally expand. + /// - If `Wt < Wd`, columns will proportionally shrink. + width_cutoffs: Vec<(u16, DisplayLayout)>, + data: TableData, +} +impl Table { pub fn create_connections_table(state: &UIState, ip_to_host: &HashMap<IpAddr, String>) -> Self { - let connections_rows = state + use DisplayLayout as D; + + let title = "Utilization by connection"; + let width_cutoffs = vec![ + (0, D::C2([32, 18])), + (80, D::C3([36, 12, 18])), + (100, D::C3([54, 18, 22])), + (120, D::C3([72, 24, 22])), + ]; + + let column_names = [ + "Connection", + "Process", + if state.cumulative_mode { + "Data (Up / Down)" + } else { + "Rate (Up / Down)" + }, + ]; + let rows = state .connections .iter() .map(|(connection, connection_data)| { - vec![ + [ display_connection_string( connection, ip_to_host, @@ -110,199 +208,212 @@ impl<'a> Table<'a> { ] }) .collect(); - let connections_title = "Utilization by connection"; - let connections_column_names = &["Connection", "Process", "Up / Down"]; - let mut breakpoints = BTreeMap::new(); - breakpoints.insert( - 0, - ColumnData { - column_count: ColumnCount::Two, - column_widths: vec![20, 23], - }, - ); - breakpoints.insert( - 70, - ColumnData { - column_count: ColumnCount::Three, - column_widths: vec![30, 12, 23], - }, - ); - breakpoints.insert( - 100, - ColumnData { - column_count: ColumnCount::Three, - column_widths: vec![60, 12, 23], - }, - ); - breakpoints.insert( - 140, - ColumnData { - column_count: ColumnCount::Three, - column_widths: vec![100, 12, 23], - }, - ); + let column_selector = Rc::new(|layout: &D| match layout { + D::C2(_) => vec![0, 2], + D::C3(_) => vec![0, 1, 2], + }); + Table { - title: connections_title, - column_names: connections_column_names, - rows: connections_rows, - breakpoints, + title, + width_cutoffs, + data: NColsTableData { + column_names, + rows, + column_selector, + } + .into(), } } + pub fn create_processes_table(state: &UIState) -> Self { - let processes_rows = state + use DisplayLayout as D; + + let title = "Utilization by process name"; + let width_cutoffs = vec![ + (0, D::C2([16, 18])), + (50, D::C3([16, 12, 20])), + (60, D::C3([24, 12, 20])), + (80, D::C3([36, 16, 24])), + ]; + + let column_names = [ + "Process", + "Connections", + if state.cumulative_mode { + "Data (Up / Down)" + } else { + "Rate (Up / Down)" + }, + ]; + let rows = state .processes .iter() .map(|(process_name, data_for_process)| { - vec![ + [ (*process_name).to_string(), data_for_process.connection_count.to_string(), display_upload_and_download(data_for_process, state.cumulative_mode), ] }) .collect(); - let processes_title = "Utilization by process name"; - let processes_column_names = &["Process", "Connections", "Up / Down"]; - let mut breakpoints = BTreeMap::new(); - breakpoints.insert( - 0, - ColumnData { - column_count: ColumnCount::Two, - column_widths: vec![12, 23], - }, - ); - breakpoints.insert( - 50, - ColumnData { - column_count: ColumnCount::Three, - column_widths: vec![12, 12, 23], - }, - ); - breakpoints.insert( - 100, - ColumnData { - column_count: ColumnCount::Three, - column_widths: vec![40, 12, 23], - }, - ); - breakpoints.insert( - 140, - ColumnData { - column_count: ColumnCount::Three, - column_widths: vec![40, 12, 23], - }, - ); + let column_selector = Rc::new(|layout: &D| match layout { + D::C2(_) => vec![0, 2], + D::C3(_) => vec![0, 1, 2], + }); + Table { - title: processes_title, - column_names: processes_column_names, - rows: processes_rows, - breakpoints, + title, + width_cutoffs, + data: NColsTableData { + column_names, + rows, + column_selector, + } + .into(), } } + pub fn create_remote_addresses_table( state: &UIState, ip_to_host: &HashMap<IpAddr, String>, ) -> Self { - let remote_addresses_rows = state + use DisplayLayout as D; + + let title = "Utilization by remote address"; + let width_cutoffs = vec![ + (0, D::C2([16, 16])), + (40, D::C2([20, 16])), + (60, D::C3([24, 10, 20])), + (100, D::C3([54, 16, 24])), + ]; + + let column_names = [ + "Remote Address", + "Connections", + if state.cumulative_mode { + "Data (Up / Down)" + } else { + "Rate (Up / Down)" + }, + ]; + let rows = state .remote_addresses .iter() .map(|(remote_address, data_for_remote_address)| { let remote_address = display_ip_or_host(*remote_address, ip_to_host); - vec![ + [ remote_address, data_for_remote_address.connection_count.to_string(), display_upload_and_download(data_for_remote_address, state.cumulative_mode), ] }) .collect(); - let remote_addresses_title = "Utilization by remote address"; - let remote_addresses_column_names = &["Remote Address", "Connections", "Up / Down"]; - let mut breakpoints = BTreeMap::new(); - breakpoints.insert( - 0, - ColumnData { - column_count: ColumnCount::Two, - column_widths: vec![15, 20], - }, - ); - breakpoints.insert( - 70, - ColumnData { - column_count: ColumnCount::Three, - column_widths: vec![30, 12, 23], - }, - ); - breakpoints.insert( - 100, - ColumnData { - column_count: ColumnCount::Three, - column_widths: vec![60, 12, 23], - }, - ); - breakpoints.insert( - 140, - ColumnData { - column_count: ColumnCount::Three, - column_widths: vec![100, 12, 23], - }, - ); + let column_selector = Rc::new(|layout: &D| match layout { + D::C2(_) => vec![0, 2], + D::C3(_) => vec![0, 1, 2], + }); + Table { - title: remote_addresses_title, - column_names: remote_addresses_column_names, - rows: remote_addresses_rows, - breakpoints, - } - } - pub fn render(&self, frame: &mut Frame<impl Backend>, rect: Rect) { - let mut column_spacing: u16 = 0; - let mut widths = &vec![]; - let mut column_count: &ColumnCount = &ColumnCount::Three; - - for (width_breakpoint, column_data) in self.breakpoints.iter() { - if *width_breakpoint < rect.width { - widths = &column_data.column_widths; - column_count = &column_data.column_count; - - let total_column_width: u16 = widths.iter().sum(); - if rect.width < total_column_width - column_count.as_u16() { - column_spacing = 0; - } else { - column_spacing = (rect.width - total_column_width) / column_count.as_u16(); - } + title, + width_cutoffs, + data: NColsTableData { + column_names, + rows, + column_selector, } + .into(), } + } - let column_names = match column_count { - ColumnCount::Two => { - vec![self.column_names[0], self.column_names[2]] // always lose the middle column when needed - } - ColumnCount::Three => vec![ - self.column_names[0], - self.column_names[1], - self.column_names[2], - ], + /// See [`Table`] for layout rules. + pub fn render(&self, frame: &mut Frame<impl Backend>, rect: Rect) { + let (computed_layout, spacer_width) = { + // pick the largest possible layout, constrained by the available width + let &(_, layout) = self + .width_cutoffs + .iter() + .rev() + .find(|(cutoff, _)| rect.width > *cutoff) + .unwrap(); // all cutoff tables have a 0-width entry + layout.compute_actual_widths(rect.width) }; |