diff --git a/src/blocks/cpu.rs b/src/blocks/cpu.rs index 907dad696..1c5389c0f 100644 --- a/src/blocks/cpu.rs +++ b/src/blocks/cpu.rs @@ -46,11 +46,11 @@ use std::str::FromStr as _; -use tokio::fs::File; -use tokio::io::{AsyncBufReadExt as _, BufReader}; - use super::prelude::*; +use crate::formatting::unit::Unit; use crate::util::read_file; +use tokio::fs::File; +use tokio::io::BufReader; const CPU_BOOST_PATH: &str = "/sys/devices/system/cpu/cpufreq/boost"; const CPU_NO_TURBO_PATH: &str = "/sys/devices/system/cpu/intel_pstate/no_turbo"; @@ -105,13 +105,6 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> { } cputime = new_cputime; - // Create barchart indicating per-core utilization - let mut barchart = String::new(); - const BOXCHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - for utilization in &utilizations { - barchart.push(BOXCHARS[(7.5 * utilization) as usize]); - } - // Read boost state on intel CPUs let boost = boost_status().await.map(|status| match status { true => "cpu_boost_on", @@ -120,7 +113,7 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> { let mut values = map!( "icon" => Value::icon_progression("cpu", utilization_avg), - "barchart" => Value::text(barchart), + "barchart" => Value::numbers(utilizations.iter().map(|u| u * 100.0).collect::>(), Unit::Percents), "utilization" => Value::percents(utilization_avg * 100.), [if !freqs.is_empty()] "frequency" => Value::hertz(freqs.iter().sum::() / (freqs.len() as f64)), [if !freqs.is_empty()] "max_frequency" => Value::hertz(freqs.iter().copied().max_by(f64::total_cmp).unwrap()), diff --git a/src/blocks/net.rs b/src/blocks/net.rs index d641d5303..08590302d 100644 --- a/src/blocks/net.rs +++ b/src/blocks/net.rs @@ -60,8 +60,8 @@ //! - `net_down` use super::prelude::*; +use crate::formatting::unit::Unit; use crate::netlink::NetDevice; -use crate::util; use itertools::Itertools as _; use regex::Regex; use std::time::Instant; @@ -153,8 +153,8 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> { "icon" => icon, "speed_down" => Value::bytes(speed_down), "speed_up" => Value::bytes(speed_up), - "graph_down" => Value::text(util::format_bar_graph(&rx_hist)), - "graph_up" => Value::text(util::format_bar_graph(&tx_hist)), + "graph_down" => Value::numbers(rx_hist, Unit::Bytes), + "graph_up" => Value::numbers(tx_hist, Unit::Bytes), [if let Some(v) = device.ip] "ip" => Value::text(v.to_string()), [if let Some(v) = device.ipv6] "ipv6" => Value::text(v.to_string()), [if let Some(v) = device.ssid()] "ssid" => Value::text(v), diff --git a/src/formatting.rs b/src/formatting.rs index 8468b2f66..3e3f785a6 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -17,6 +17,7 @@ //! --------------------------|------------------ //! Text | `str` //! Number | `eng` +//! Numbers | `eng` //! Datetime | `datetime` //! Duration | `duration` //! [Flag](#how-to-use-flags) | N/A @@ -60,6 +61,8 @@ //! `range` | a range of allowed values, in the format `..`, inclusive. Both start and end are optional. Can be used to, for example, hide the block when the value is not in a given range. | `..` //! `show` | show this value. Can be used with `range` for conditional formatting | `true` //! +//! Note: For [`Value::Numbers`] only the last value will be used for formatting. +//! //! ## `bar` - Display numbers as progress bars //! //! Argument | Description |Default value @@ -68,6 +71,8 @@ //! `max_value` | which value is treated as "full". For example, for battery level `100` is full. | `100` //! `vertical` or `v` | whether to render the bar vertically or not | `false` //! +//! Note: For [`Value::Numbers`] the result will be a bar chart: if vertical, each inner value will be a single bar; if horizontal, only the last value will be displayed as a single bar. +//! //! ## `tally` - Display numbers as tally marks //! //! Argument | Description |Default value diff --git a/src/formatting/formatter/bar.rs b/src/formatting/formatter/bar.rs index bce5558b0..7b50be758 100644 --- a/src/formatting/formatter/bar.rs +++ b/src/formatting/formatter/bar.rs @@ -42,34 +42,70 @@ impl BarFormatter { vertical, }) } -} -const HORIZONTAL_BAR_CHARS: [char; 9] = [ - ' ', '\u{258f}', '\u{258e}', '\u{258d}', '\u{258c}', '\u{258b}', '\u{258a}', '\u{2589}', - '\u{2588}', -]; + #[inline] + fn norm(&self, val: f64, max_value: f64) -> f64 { + // NOTE: This has the drawback of potentially ignoring the max_value set by the user. + Some(val / max_value) + .filter(|v| v.is_finite()) + .unwrap_or(0.0) + .clamp(0.0, 1.0) + } + + #[inline] + fn format_single_vertical(&self, val: f64, max_value: f64) -> char { + let val = self.norm(val, max_value); + VERTICAL_BAR_CHARS[(val * 8.0).ceil() as usize] + } + + #[inline] + fn format_horizontal_cell(&self, val: f64, max_value: f64, i: usize) -> char { + let val = self.norm(val, max_value); + let chars_to_fill = val * self.width as f64; + HORIZONTAL_BAR_CHARS[((chars_to_fill - i as f64).clamp(0.0, 1.0) * 8.0).ceil() as usize] + } + + #[inline] + fn format_horizontal_bar(&self, val: f64, max_value: f64) -> String { + (0..self.width) + .map(|i| self.format_horizontal_cell(val, max_value, i)) + .collect() + } +} -const VERTICAL_BAR_CHARS: [char; 9] = [ - ' ', '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', - '\u{2588}', -]; +const HORIZONTAL_BAR_CHARS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; +const VERTICAL_BAR_CHARS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; impl Formatter for BarFormatter { fn format(&self, val: &Value, _config: &SharedConfig) -> Result { match val { - &Value::Number { mut val, .. } => { - val = (val / self.max_value).clamp(0., 1.); + Value::Number { val, .. } => { if self.vertical { - let vert_char = VERTICAL_BAR_CHARS[(val * 8.) as usize]; - Ok((0..self.width).map(|_| vert_char).collect()) + let c = self.format_single_vertical(*val, self.max_value.max(*val)); + Ok(std::iter::repeat_n(c, self.width).collect()) } else { - let chars_to_fill = val * self.width as f64; - Ok((0..self.width) - .map(|i| { - HORIZONTAL_BAR_CHARS - [((chars_to_fill - i as f64).clamp(0., 1.) * 8.) as usize] - }) + Ok(self.format_horizontal_bar(*val, self.max_value.max(*val))) + } + } + Value::Numbers { vals, .. } => { + if self.vertical { + // NOTE: print at most `width` values as a windowed chart + let start = vals.len().saturating_sub(self.width); + let shown = vals.len() - start; + let max = vals + .iter() + .copied() + .max_by(f64::total_cmp) + .unwrap_or(self.max_value); + + Ok(std::iter::repeat_n(0.0, self.width - shown) + .chain(vals[start..].iter().copied()) + .map(|val| self.format_single_vertical(val, self.max_value.max(max))) .collect()) + } else { + // NOTE: print the last value as a horizontal bar + let last = vals.last().copied().unwrap_or(0.0); + Ok(self.format_horizontal_bar(last, self.max_value.max(last))) } } other => Err(FormatError::IncompatibleFormatter { @@ -79,3 +115,115 @@ impl Formatter for BarFormatter { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::formatting::unit::Unit; + + fn fmt_horiz(width: usize, max_value: f64) -> BarFormatter { + BarFormatter { + width, + max_value, + vertical: false, + } + } + + fn fmt_vert(width: usize, max_value: f64) -> BarFormatter { + BarFormatter { + width, + max_value, + vertical: true, + } + } + + fn number(val: f64) -> Value { + Value::Number { + val, + unit: Unit::None, + } + } + + fn numbers(vals: &[f64]) -> Value { + Value::Numbers { + vals: vals.to_vec(), + unit: Unit::None, + } + } + + #[test] + fn single_number_horizontal_basic() { + let fmt = fmt_horiz(8, 8.0); + let out = fmt.format(&number(4.0), &Default::default()).unwrap(); + assert_eq!(out, "████ "); + } + + #[test] + fn single_number_horizontal_fractional() { + let fmt = fmt_horiz(8, 8.0); + let out = fmt.format(&number(3.5), &Default::default()).unwrap(); + assert_eq!(out, "███▌ "); + } + + #[test] + fn single_number_vertical_basic() { + let fmt = fmt_vert(5, 8.0); + let out = fmt.format(&number(4.0), &Default::default()).unwrap(); + assert_eq!(out, "▄▄▄▄▄"); + } + + #[test] + fn single_number_vertical_clamps() { + let fmt = fmt_vert(3, 8.0); + let out = fmt + .format(&number(999.0), &SharedConfig::default()) + .unwrap(); + assert_eq!(out, "███"); + + let fmt = fmt_vert(3, 8.0); + let out = fmt.format(&number(-1.0), &Default::default()).unwrap(); + assert_eq!(out, " "); + } + + #[test] + fn multiple_values_horizontal_uses_last_value() { + let fmt = fmt_horiz(8, 8.0); + let out = fmt + .format(&numbers(&[1.0, 2.0, 4.0]), &Default::default()) + .unwrap(); + assert_eq!(out, "████ "); + } + + #[test] + fn multiple_values_vertical_graph() { + let fmt = fmt_vert(9, 8.0); + let out = fmt + .format( + &numbers(&[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]), + &SharedConfig::default(), + ) + .unwrap(); + assert_eq!(out, " ▁▂▃▄▅▆▇█"); + } + + #[test] + fn multiple_values_vertical_pads_when_short() { + let fmt = fmt_vert(5, 8.0); + let out = fmt + .format(&numbers(&[4.0, 8.0]), &Default::default()) + .unwrap(); + assert_eq!(out, " ▄█"); + } + + #[test] + fn multiple_values_vertical_truncates_when_long() { + let fmt = fmt_vert(4, 8.0); + let out = fmt + .format( + &numbers(&[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]), + &Default::default(), + ) + .unwrap(); + assert_eq!(out, "▅▆▇█"); + } +} diff --git a/src/formatting/formatter/eng.rs b/src/formatting/formatter/eng.rs index 9e6e60a1e..d437384f6 100644 --- a/src/formatting/formatter/eng.rs +++ b/src/formatting/formatter/eng.rs @@ -197,6 +197,16 @@ impl Formatter for EngFormatter { Ok(retval) } + &Value::Numbers { ref vals, unit } => { + // NOTE: by default, Value::Numbers will be formatted as the last value in the series + Ok(self.format( + &Value::Number { + val: vals.last().copied().unwrap_or(0.0), + unit, + }, + _config, + )?) + } other => Err(FormatError::IncompatibleFormatter { ty: other.type_name(), fmt: "eng", diff --git a/src/formatting/value.rs b/src/formatting/value.rs index b5e3d232c..aa922e9aa 100644 --- a/src/formatting/value.rs +++ b/src/formatting/value.rs @@ -18,6 +18,7 @@ pub enum ValueInner { Text(String), Icon(Cow<'static, str>, Option), Number { val: f64, unit: Unit }, + Numbers { vals: Vec, unit: Unit }, Datetime(DateTime, Option), Duration(Duration), Flag, @@ -29,6 +30,7 @@ impl ValueInner { ValueInner::Text(..) => "Text", ValueInner::Icon(..) => "Icon", ValueInner::Number { .. } => "Number", + ValueInner::Numbers { .. } => "Numbers", ValueInner::Datetime(..) => "Datetime", ValueInner::Duration(..) => "Duration", ValueInner::Flag => "Flag", @@ -129,6 +131,17 @@ impl Value { pub fn number(val: impl IntoF64) -> Self { Self::number_unit(val, Unit::None) } + + pub fn numbers(vals: T, unit: Unit) -> Self + where + T: IntoIterator, + V: IntoF64, + { + Self::new(ValueInner::Numbers { + vals: vals.into_iter().map(IntoF64::into_f64).collect(), + unit, + }) + } } /// Set options @@ -151,7 +164,9 @@ impl Value { pub fn default_formatter(&self) -> &'static dyn formatter::Formatter { match &self.inner { ValueInner::Text(_) | ValueInner::Icon(..) => &formatter::DEFAULT_STRING_FORMATTER, - ValueInner::Number { .. } => &formatter::DEFAULT_NUMBER_FORMATTER, + ValueInner::Number { .. } | ValueInner::Numbers { .. } => { + &formatter::DEFAULT_NUMBER_FORMATTER + } ValueInner::Datetime { .. } => &*formatter::DEFAULT_DATETIME_FORMATTER, ValueInner::Duration { .. } => &formatter::DEFAULT_DURATION_FORMATTER, ValueInner::Flag => &formatter::DEFAULT_FLAG_FORMATTER,