feature: add the ability to configure the disk widget's table columns (#1625)

* a bit of refactoring here...

* some refactoring, add columns

* cleanup

* add disk column feature

* update changelog
This commit is contained in:
Clement Tsang 2024-11-17 21:28:20 -05:00 committed by GitHub
parent c8cba49463
commit 196d6d18c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 413 additions and 154 deletions

View file

@ -22,6 +22,10 @@ That said, these are more guidelines rather than hardset rules, though the proje
## [0.11.0] - Unreleased
### Features
- [#1625](https://github.com/ClementTsang/bottom/pull/1625): Add the ability to configure the disk widget's table columns.
### Bug Fixes
- [#1551](https://github.com/ClementTsang/bottom/pull/1551): Fix missing parent section names in default config.

View file

@ -8,24 +8,34 @@
[flags]
# Whether to hide the average cpu entry.
#hide_avg_cpu = false
# Whether to use dot markers rather than braille.
#dot_marker = false
# The update rate of the application.
#rate = "1s"
# Whether to put the CPU legend to the left.
#cpu_left_legend = false
# Whether to set CPU% on a process to be based on the total CPU or just current usage.
#current_usage = false
# Whether to set CPU% on a process to be based on the total CPU or per-core CPU% (not divided by the number of cpus).
#unnormalized_cpu = false
# Whether to group processes with the same name together by default.
#group_processes = false
# Whether to make process searching case sensitive by default.
#case_sensitive = false
# Whether to make process searching look for matching the entire word by default.
#whole_word = false
# Whether to make process searching use regex by default.
#regex = false
# The temperature unit. One of the following, defaults to "c" for Celsius:
#temperature_type = "c"
##temperature_type = "k"
@ -33,79 +43,112 @@
##temperature_type = "kelvin"
##temperature_type = "fahrenheit"
##temperature_type = "celsius"
# The default time interval (in milliseconds).
#default_time_value = "60s"
# The time delta on each zoom in/out action (in milliseconds).
#time_delta = 15000
# Hides the time scale.
#hide_time = false
# Override layout default widget
#default_widget_type = "proc"
#default_widget_count = 1
# Expand selected widget upon starting the app
#expanded = true
# Use basic mode
#basic = false
# Use the old network legend style
#use_old_network_legend = false
# Remove space in tables
#hide_table_gap = false
# Show the battery widgets
#battery = false
# Disable mouse clicks
#disable_click = false
# Show memory values in the processes widget as values by default
#process_memory_as_value = false
# Show tree mode by default in the processes widget.
#tree = false
# Shows an indicator in table widgets tracking where in the list you are.
#show_table_scroll_position = false
# Show processes as their commands by default in the process widget.
#process_command = false
# Displays the network widget with binary prefixes.
#network_use_binary_prefix = false
# Displays the network widget using bytes.
#network_use_bytes = false
# Displays the network widget with a log scale.
#network_use_log = false
# Hides advanced options to stop a process on Unix-like systems.
#disable_advanced_kill = false
# Hide GPU(s) information
#disable_gpu = false
# Shows cache and buffer memory
#enable_cache_memory = false
# How much data is stored at once in terms of time.
#retention = "10m"
# Where to place the legend for the memory widget. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right".
#memory_legend = "top-right"
# Where to place the legend for the network widget. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right".
#network_legend = "top-right"
# Processes widget configuration
#[processes]
# The columns shown by the process widget. The following columns are supported (the GPU columns are only available if the GPU feature is enabled when built):
# PID, Name, CPU%, Mem%, R/s, W/s, T.Read, T.Write, User, State, Time, GMem%, GPU%
#columns = ["PID", "Name", "CPU%", "Mem%", "R/s", "W/s", "T.Read", "T.Write", "User", "State", "GMem%", "GPU%"]
# CPU widget configuration
#[cpu]
# One of "all" (default), "average"/"avg"
# default = "average"
#default = "average"
# Disk widget configuration
#[disk]
# The columns shown by the process widget. The following columns are supported:
# Disk, Mount, Used, Free, Total, Used%, Free%, R/s, W/s
#columns = ["Disk", "Mount", "Used", "Free", "Total", "Used%", "R/s", "W/s"]
# By default, there are no disk name filters enabled. These can be turned on to filter out specific data entries if you
# don't want to see them. An example use case is provided below.
#[disk.name_filter]
# Whether to ignore any matches. Defaults to true.
#is_list_ignored = true
# A list of filters to try and match.
#list = ["/dev/sda\\d+", "/dev/nvme0n1p2"]
# Whether to use regex. Defaults to false.
#regex = true
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
#whole_word = false
@ -113,51 +156,63 @@
#[disk.mount_filter]
# Whether to ignore any matches. Defaults to true.
#is_list_ignored = true
# A list of filters to try and match.
#list = ["/mnt/.*", "/boot"]
# Whether to use regex. Defaults to false.
#regex = true
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
#whole_word = false
# Temperature widget configuration
#[temperature]
# By default, there are no temperature sensor filters enabled. An example use case is provided below.
#[temperature.sensor_filter]
# Whether to ignore any matches. Defaults to true.
#is_list_ignored = true
# A list of filters to try and match.
#list = ["cpu", "wifi"]
# Whether to use regex. Defaults to false.
#regex = false
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
#whole_word = false
# Network widget configuration
#[network]
# By default, there are no network interface filters enabled. An example use case is provided below.
#[network.interface_filter]
# Whether to ignore any matches. Defaults to true.
#is_list_ignored = true
# A list of filters to try and match.
#list = ["virbr0.*"]
# Whether to use regex. Defaults to false.
#regex = true
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
#whole_word = false
# These are all the components that support custom theming. Note that colour support
# will depend on terminal support.
#[styles] # Uncomment if you want to use custom styling
# Built-in themes. Valid values are:
# - "default"
# - "default-light"

View file

@ -183,10 +183,35 @@
}
}
},
"DiskColumn": {
"type": "string",
"enum": [
"Disk",
"Free",
"Free%",
"Mount",
"R/s",
"Read",
"Rps",
"Total",
"Used",
"Used%",
"W/s",
"Wps",
"Write"
]
},
"DiskConfig": {
"description": "Disk configuration.",
"type": "object",
"properties": {
"columns": {
"description": "A list of disk widget columns.",
"type": "array",
"items": {
"$ref": "#/definitions/DiskColumn"
}
},
"mount_filter": {
"description": "A filter over the mount names.",
"anyOf": [

View file

@ -51,7 +51,7 @@ def main():
read_file = re.sub(
r"^#(\s\s+)([a-zA-Z\[])", r"\2", read_file, flags=re.MULTILINE
)
print(f"uncommented file: \n{read_file}")
print(f"uncommented file: \n{read_file}\n=====\n")
toml_str = tomllib.loads(read_file)
else:

View file

@ -25,7 +25,7 @@ use crate::{
processes::{Pid, ProcessHarvest},
temperature, Data,
},
utils::data_prefixes::*,
dec_bytes_per_second_string,
};
pub type TimeOffset = f64;
@ -423,20 +423,11 @@ impl DataCollection {
*io_curr = (r_rate, w_rate);
*io_prev = (io_r_pt, io_w_pt);
// TODO: idk why I'm generating this here tbh
if let Some(io_labels) = self.io_labels.get_mut(itx) {
let converted_read = get_decimal_bytes(r_rate);
let converted_write = get_decimal_bytes(w_rate);
*io_labels = (
if r_rate >= GIGA_LIMIT {
format!("{:.*}{}/s", 1, converted_read.0, converted_read.1)
} else {
format!("{:.*}{}/s", 0, converted_read.0, converted_read.1)
},
if w_rate >= GIGA_LIMIT {
format!("{:.*}{}/s", 1, converted_write.0, converted_write.1)
} else {
format!("{:.*}{}/s", 0, converted_write.0, converted_write.1)
},
dec_bytes_per_second_string(r_rate),
dec_bytes_per_second_string(w_rate),
);
}
}

View file

@ -25,11 +25,16 @@ impl SortOrder {
SortOrder::Descending => SortOrder::Ascending,
}
}
/// A hack to get a const default.
pub const fn const_default() -> Self {
Self::Ascending
}
}
impl Default for SortOrder {
fn default() -> Self {
Self::Ascending
Self::const_default()
}
}
@ -195,18 +200,18 @@ where
/// Creates a new [`SortColumn`] with a hard width, which has no shortcut
/// and sorts by default in ascending order ([`SortOrder::Ascending`]).
pub fn hard(inner: T, width: u16) -> Self {
pub const fn hard(inner: T, width: u16) -> Self {
Self {
inner,
bounds: ColumnWidthBounds::Hard(width),
is_hidden: false,
default_order: SortOrder::default(),
default_order: SortOrder::const_default(),
}
}
/// Creates a new [`SortColumn`] with a soft width, which has no shortcut
/// and sorts by default in ascending order ([`SortOrder::Ascending`]).
pub fn soft(inner: T, max_percentage: Option<f32>) -> Self {
pub const fn soft(inner: T, max_percentage: Option<f32>) -> Self {
Self {
inner,
bounds: ColumnWidthBounds::Soft {
@ -214,7 +219,7 @@ where
max_percentage,
},
is_hidden: false,
default_order: SortOrder::default(),
default_order: SortOrder::const_default(),
}
}
@ -225,7 +230,7 @@ where
}
/// Sets the default sort order to [`SortOrder::Descending`].
pub fn default_descending(mut self) -> Self {
pub const fn default_descending(mut self) -> Self {
self.default_order = SortOrder::Descending;
self
}

View file

@ -268,24 +268,34 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott
[flags]
# Whether to hide the average cpu entry.
#hide_avg_cpu = false
# Whether to use dot markers rather than braille.
#dot_marker = false
# The update rate of the application.
#rate = "1s"
# Whether to put the CPU legend to the left.
#cpu_left_legend = false
# Whether to set CPU% on a process to be based on the total CPU or just current usage.
#current_usage = false
# Whether to set CPU% on a process to be based on the total CPU or per-core CPU% (not divided by the number of cpus).
#unnormalized_cpu = false
# Whether to group processes with the same name together by default.
#group_processes = false
# Whether to make process searching case sensitive by default.
#case_sensitive = false
# Whether to make process searching look for matching the entire word by default.
#whole_word = false
# Whether to make process searching use regex by default.
#regex = false
# The temperature unit. One of the following, defaults to "c" for Celsius:
#temperature_type = "c"
##temperature_type = "k"
@ -293,79 +303,112 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott
##temperature_type = "kelvin"
##temperature_type = "fahrenheit"
##temperature_type = "celsius"
# The default time interval (in milliseconds).
#default_time_value = "60s"
# The time delta on each zoom in/out action (in milliseconds).
#time_delta = 15000
# Hides the time scale.
#hide_time = false
# Override layout default widget
#default_widget_type = "proc"
#default_widget_count = 1
# Expand selected widget upon starting the app
#expanded = true
# Use basic mode
#basic = false
# Use the old network legend style
#use_old_network_legend = false
# Remove space in tables
#hide_table_gap = false
# Show the battery widgets
#battery = false
# Disable mouse clicks
#disable_click = false
# Show memory values in the processes widget as values by default
#process_memory_as_value = false
# Show tree mode by default in the processes widget.
#tree = false
# Shows an indicator in table widgets tracking where in the list you are.
#show_table_scroll_position = false
# Show processes as their commands by default in the process widget.
#process_command = false
# Displays the network widget with binary prefixes.
#network_use_binary_prefix = false
# Displays the network widget using bytes.
#network_use_bytes = false
# Displays the network widget with a log scale.
#network_use_log = false
# Hides advanced options to stop a process on Unix-like systems.
#disable_advanced_kill = false
# Hide GPU(s) information
#disable_gpu = false
# Shows cache and buffer memory
#enable_cache_memory = false
# How much data is stored at once in terms of time.
#retention = "10m"
# Where to place the legend for the memory widget. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right".
#memory_legend = "top-right"
# Where to place the legend for the network widget. One of "none", "top-left", "top", "top-right", "left", "right", "bottom-left", "bottom", "bottom-right".
#network_legend = "top-right"
# Processes widget configuration
#[processes]
# The columns shown by the process widget. The following columns are supported (the GPU columns are only available if the GPU feature is enabled when built):
# PID, Name, CPU%, Mem%, R/s, W/s, T.Read, T.Write, User, State, Time, GMem%, GPU%
#columns = ["PID", "Name", "CPU%", "Mem%", "R/s", "W/s", "T.Read", "T.Write", "User", "State", "GMem%", "GPU%"]
# CPU widget configuration
#[cpu]
# One of "all" (default), "average"/"avg"
# default = "average"
#default = "average"
# Disk widget configuration
#[disk]
# The columns shown by the process widget. The following columns are supported:
# Disk, Mount, Used, Free, Total, Used%, Free%, R/s, W/s
#columns = ["Disk", "Mount", "Used", "Free", "Total", "Used%", "R/s", "W/s"]
# By default, there are no disk name filters enabled. These can be turned on to filter out specific data entries if you
# don't want to see them. An example use case is provided below.
#[disk.name_filter]
# Whether to ignore any matches. Defaults to true.
#is_list_ignored = true
# A list of filters to try and match.
#list = ["/dev/sda\\d+", "/dev/nvme0n1p2"]
# Whether to use regex. Defaults to false.
#regex = true
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
#whole_word = false
@ -373,51 +416,63 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott
#[disk.mount_filter]
# Whether to ignore any matches. Defaults to true.
#is_list_ignored = true
# A list of filters to try and match.
#list = ["/mnt/.*", "/boot"]
# Whether to use regex. Defaults to false.
#regex = true
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
#whole_word = false
# Temperature widget configuration
#[temperature]
# By default, there are no temperature sensor filters enabled. An example use case is provided below.
#[temperature.sensor_filter]
# Whether to ignore any matches. Defaults to true.
#is_list_ignored = true
# A list of filters to try and match.
#list = ["cpu", "wifi"]
# Whether to use regex. Defaults to false.
#regex = false
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
#whole_word = false
# Network widget configuration
#[network]
# By default, there are no network interface filters enabled. An example use case is provided below.
#[network.interface_filter]
# Whether to ignore any matches. Defaults to true.
#is_list_ignored = true
# A list of filters to try and match.
#list = ["virbr0.*"]
# Whether to use regex. Defaults to false.
#regex = true
# Whether to be case-sensitive. Defaults to false.
#case_sensitive = false
# Whether to be require matching the whole word. Defaults to false.
#whole_word = false
# These are all the components that support custom theming. Note that colour support
# will depend on terminal support.
#[styles] # Uncomment if you want to use custom styling
# Built-in themes. Valid values are:
# - "default"
# - "default-light"

View file

@ -402,15 +402,15 @@ pub fn convert_network_points(
};
if need_four_points {
let rx_display = format!("{:.*}{}", 1, rx_converted_result.0, rx_converted_result.1);
let rx_display = format!("{:.1}{}", rx_converted_result.0, rx_converted_result.1);
let total_rx_display = Some(format!(
"{:.*}{}",
1, total_rx_converted_result.0, total_rx_converted_result.1
"{:.1}{}",
total_rx_converted_result.0, total_rx_converted_result.1
));
let tx_display = format!("{:.*}{}", 1, tx_converted_result.0, tx_converted_result.1);
let tx_display = format!("{:.1}{}", tx_converted_result.0, tx_converted_result.1);
let total_tx_display = Some(format!(
"{:.*}{}",
1, total_tx_converted_result.0, total_tx_converted_result.1
"{:.1}{}",
total_tx_converted_result.0, total_tx_converted_result.1
));
ConvertedNetworkData {
rx,
@ -474,35 +474,38 @@ pub fn convert_network_points(
/// Returns a string given a value that is converted to the closest binary
/// variant. If the value is greater than a gibibyte, then it will return a
/// decimal place.
#[inline]
pub fn binary_byte_string(value: u64) -> String {
let converted_values = get_binary_bytes(value);
if value >= GIBI_LIMIT {
format!("{:.*}{}", 1, converted_values.0, converted_values.1)
format!("{:.1}{}", converted_values.0, converted_values.1)
} else {
format!("{:.*}{}", 0, converted_values.0, converted_values.1)
format!("{:.0}{}", converted_values.0, converted_values.1)
}
}
/// Returns a string given a value that is converted to the closest SI-variant.
/// If the value is greater than a giga-X, then it will return a decimal place.
#[inline]
pub fn dec_bytes_per_string(value: u64) -> String {
let converted_values = get_decimal_bytes(value);
if value >= GIGA_LIMIT {
format!("{:.*}{}", 1, converted_values.0, converted_values.1)
format!("{:.1}{}", converted_values.0, converted_values.1)
} else {
format!("{:.*}{}", 0, converted_values.0, converted_values.1)
format!("{:.0}{}", converted_values.0, converted_values.1)
}
}
/// Returns a string given a value that is converted to the closest SI-variant,
/// per second. If the value is greater than a giga-X, then it will return a
/// decimal place.
#[inline]
pub fn dec_bytes_per_second_string(value: u64) -> String {
let converted_values = get_decimal_bytes(value);
if value >= GIGA_LIMIT {
format!("{:.*}{}/s", 1, converted_values.0, converted_values.1)
format!("{:.1}{}/s", converted_values.0, converted_values.1)
} else {
format!("{:.*}{}/s", 0, converted_values.0, converted_values.1)
format!("{:.0}{}/s", converted_values.0, converted_values.1)
}
}
@ -511,9 +514,9 @@ pub fn dec_bytes_per_second_string(value: u64) -> String {
pub fn dec_bytes_string(value: u64) -> String {
let converted_values = get_decimal_bytes(value);
if value >= GIGA_LIMIT {
format!("{:.*}{}", 1, converted_values.0, converted_values.1)
format!("{:.1}{}", converted_values.0, converted_values.1)
} else {
format!("{:.*}{}", 0, converted_values.0, converted_values.1)
format!("{:.0}{}", converted_values.0, converted_values.1)
}
}

View file

@ -279,6 +279,8 @@ fn generate_schema() -> anyhow::Result<()> {
use itertools::Itertools;
use strum::VariantArray;
// TODO: Maybe make this case insensitive? See https://stackoverflow.com/a/68639341
let proc_columns = schema.definitions.get_mut("ProcColumn").unwrap();
match proc_columns {
schemars::schema::Schema::Object(proc_columns) => {
@ -293,6 +295,21 @@ fn generate_schema() -> anyhow::Result<()> {
}
_ => anyhow::bail!("missing proc columns definition"),
}
let disk_columns = schema.definitions.get_mut("DiskColumn").unwrap();
match disk_columns {
schemars::schema::Schema::Object(disk_columns) => {
let enums = disk_columns.enum_values.as_mut().unwrap();
*enums = widgets::DiskColumn::VARIANTS
.iter()
.flat_map(|var| var.get_schema_names())
.sorted()
.map(|v| serde_json::Value::String(v.to_string()))
.dedup()
.collect();
}
_ => anyhow::bail!("missing disk columns definition"),
}
}
let metadata = schema.schema.metadata.as_mut().unwrap();

View file

@ -403,7 +403,11 @@ pub(crate) fn init_app(
Disk => {
disk_state_map.insert(
widget.widget_id,
DiskTableWidget::new(&app_config_fields, &styling),
DiskTableWidget::new(
&app_config_fields,
&styling,
config.disk.as_ref().map(|cfg| cfg.columns.as_slice()),
),
);
}
Temp => {

View file

@ -6,7 +6,7 @@ use serde::Deserialize;
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
#[cfg_attr(test, derive(PartialEq, Eq))]
pub enum CpuDefault {
pub(crate) enum CpuDefault {
#[default]
All,
#[serde(alias = "avg")]
@ -17,9 +17,9 @@ pub enum CpuDefault {
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))]
pub struct CpuConfig {
pub(crate) struct CpuConfig {
#[serde(default)]
pub default: CpuDefault,
pub(crate) default: CpuDefault,
}
#[cfg(test)]

View file

@ -1,15 +1,45 @@
use serde::Deserialize;
use crate::options::DiskColumn;
use super::IgnoreList;
/// Disk configuration.
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))]
pub struct DiskConfig {
pub(crate) struct DiskConfig {
/// A filter over the disk names.
pub name_filter: Option<IgnoreList>,
pub(crate) name_filter: Option<IgnoreList>,
/// A filter over the mount names.
pub mount_filter: Option<IgnoreList>,
pub(crate) mount_filter: Option<IgnoreList>,
/// A list of disk widget columns.
#[serde(default)]
pub(crate) columns: Vec<DiskColumn>, // TODO: make this more composable(?) in the future, we might need to rethink how it's done for custom widgets
}
#[cfg(test)]
mod test {
use super::DiskConfig;
#[test]
fn empty_column_setting() {
let config = "";
let generated: DiskConfig = toml_edit::de::from_str(config).unwrap();
assert!(generated.columns.is_empty());
}
#[test]
fn valid_disk_column_settings() {
let config = r#"columns = ["disk", "mount", "used", "free", "total", "used%", "free%", "r/s", "w/s"]"#;
toml_edit::de::from_str::<DiskConfig>(config).expect("Should succeed!");
}
#[test]
fn bad_disk_column_settings() {
let config = r#"columns = ["diskk"]"#;
toml_edit::de::from_str::<DiskConfig>(config).expect_err("Should error out!");
}
}

View file

@ -6,7 +6,7 @@ use super::IgnoreList;
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))]
pub struct NetworkConfig {
pub(crate) struct NetworkConfig {
/// A filter over the network interface names.
pub interface_filter: Option<IgnoreList>,
pub(crate) interface_filter: Option<IgnoreList>,
}

View file

@ -6,10 +6,10 @@ use crate::widgets::ProcColumn;
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))]
pub struct ProcessesConfig {
pub(crate) struct ProcessesConfig {
/// A list of process widget columns.
#[serde(default)]
pub columns: Vec<ProcColumn>,
pub(crate) columns: Vec<ProcColumn>, // TODO: make this more composable(?) in the future, we might need to rethink how it's done for custom widgets
}
#[cfg(test)]
@ -32,7 +32,7 @@ mod test {
}
#[test]
fn process_column_settings() {
fn valid_process_column_config() {
let config = r#"
columns = ["CPU%", "PiD", "user", "MEM", "Tread", "T.Write", "Rps", "W/s", "tiMe", "USER", "state"]
"#;
@ -57,13 +57,13 @@ mod test {
}
#[test]
fn process_column_settings_2() {
fn bad_process_column_config() {
let config = r#"columns = ["MEM", "TWrite", "Cpuz", "read", "wps"]"#;
toml_edit::de::from_str::<ProcessesConfig>(config).expect_err("Should error out!");
}
#[test]
fn process_column_settings_3() {
fn valid_process_column_config_2() {
let config = r#"columns = ["Twrite", "T.Write"]"#;
let generated: ProcessesConfig = toml_edit::de::from_str(config).unwrap();
assert_eq!(

View file

@ -6,7 +6,7 @@ use super::IgnoreList;
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))]
pub struct TempConfig {
pub(crate) struct TempConfig {
/// A filter over the sensor names.
pub sensor_filter: Option<IgnoreList>,
pub(crate) sensor_filter: Option<IgnoreList>,
}

View file

@ -165,7 +165,7 @@ pub struct CpuWidgetState {
}
impl CpuWidgetState {
pub fn new(
pub(crate) fn new(
config: &AppConfigFields, default_selection: CpuDefault, current_display_time: u64,
autohide_timer: Option<Instant>, colours: &ColourPalette,
) -> Self {

View file

@ -1,5 +1,7 @@
use std::{borrow::Cow, cmp::max, num::NonZeroU16};
use serde::Deserialize;
use crate::{
app::AppConfigFields,
canvas::components::data_table::{
@ -26,11 +28,7 @@ impl DiskWidgetData {
fn total_space(&self) -> Cow<'static, str> {
if let Some(total_bytes) = self.total_bytes {
let converted_total_space = get_decimal_bytes(total_bytes);
format!(
"{:.*}{}",
0, converted_total_space.0, converted_total_space.1
)
.into()
format!("{:.0}{}", converted_total_space.0, converted_total_space.1).into()
} else {
"N/A".into()
}
@ -39,7 +37,7 @@ impl DiskWidgetData {
fn free_space(&self) -> Cow<'static, str> {
if let Some(free_bytes) = self.free_bytes {
let converted_free_space = get_decimal_bytes(free_bytes);
format!("{:.*}{}", 0, converted_free_space.0, converted_free_space.1).into()
format!("{:.0}{}", converted_free_space.0, converted_free_space.1).into()
} else {
"N/A".into()
}
@ -48,7 +46,7 @@ impl DiskWidgetData {
fn used_space(&self) -> Cow<'static, str> {
if let Some(used_bytes) = self.used_bytes {
let converted_free_space = get_decimal_bytes(used_bytes);
format!("{:.*}{}", 0, converted_free_space.0, converted_free_space.1).into()
format!("{:.0}{}", converted_free_space.0, converted_free_space.1).into()
} else {
"N/A".into()
}
@ -58,19 +56,16 @@ impl DiskWidgetData {
if let (Some(free_bytes), Some(summed_total_bytes)) =
(self.free_bytes, self.summed_total_bytes)
{
Some(free_bytes as f64 / summed_total_bytes as f64 * 100_f64)
if summed_total_bytes > 0 {
Some(free_bytes as f64 / summed_total_bytes as f64 * 100_f64)
} else {
None
}
} else {
None
}
}
fn free_percent_string(&self) -> Cow<'static, str> {
match self.free_percent() {
Some(val) => format!("{val:.1}%").into(),
None => "N/A".into(),
}
}
fn used_percent(&self) -> Option<f64> {
if let (Some(used_bytes), Some(summed_total_bytes)) =
(self.used_bytes, self.summed_total_bytes)
@ -84,16 +79,15 @@ impl DiskWidgetData {
None
}
}
fn used_percent_string(&self) -> Cow<'static, str> {
match self.used_percent() {
Some(val) => format!("{val:.1}%").into(),
None => "N/A".into(),
}
}
}
pub enum DiskWidgetColumn {
#[derive(Debug, Clone)]
#[cfg_attr(
feature = "generate_schema",
derive(schemars::JsonSchema, strum::VariantArray)
)]
#[cfg_attr(test, derive(PartialEq, Eq))]
pub enum DiskColumn {
Disk,
Mount,
Used,
@ -105,45 +99,91 @@ pub enum DiskWidgetColumn {
IoWrite,
}
impl ColumnHeader for DiskWidgetColumn {
impl<'de> Deserialize<'de> for DiskColumn {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?.to_lowercase();
match value.as_str() {
"disk" => Ok(DiskColumn::Disk),
"mount" => Ok(DiskColumn::Mount),
"used" => Ok(DiskColumn::Used),
"free" => Ok(DiskColumn::Free),
"total" => Ok(DiskColumn::Total),
"usedpercent" | "used%" => Ok(DiskColumn::UsedPercent),
"freepercent" | "free%" => Ok(DiskColumn::FreePercent),
"r/s" => Ok(DiskColumn::IoRead),
"w/s" => Ok(DiskColumn::IoWrite),
_ => Err(serde::de::Error::custom(
"doesn't match any disk column name",
)),
}
}
}
impl DiskColumn {
/// An ugly hack to generate the JSON schema.
#[cfg(feature = "generate_schema")]
pub fn get_schema_names(&self) -> &[&'static str] {
match self {
DiskColumn::Disk => &["Disk"],
DiskColumn::Mount => &["Mount"],
DiskColumn::Used => &["Used"],
DiskColumn::Free => &["Free"],
DiskColumn::Total => &["Total"],
DiskColumn::UsedPercent => &["Used%"],
DiskColumn::FreePercent => &["Free%"],
DiskColumn::IoRead => &["R/s", "Read", "Rps"],
DiskColumn::IoWrite => &["W/s", "Write", "Wps"],
}
}
}
impl ColumnHeader for DiskColumn {
fn text(&self) -> Cow<'static, str> {
match self {
DiskWidgetColumn::Disk => "Disk(d)",
DiskWidgetColumn::Mount => "Mount(m)",
DiskWidgetColumn::Used => "Used(u)",
DiskWidgetColumn::Free => "Free(n)",
DiskWidgetColumn::UsedPercent => "Used%(p)",
DiskWidgetColumn::FreePercent => "Free%",
DiskWidgetColumn::Total => "Total(t)",
DiskWidgetColumn::IoRead => "R/s(r)",
DiskWidgetColumn::IoWrite => "W/s(w)",
DiskColumn::Disk => "Disk(d)",
DiskColumn::Mount => "Mount(m)",
DiskColumn::Used => "Used(u)",
DiskColumn::Free => "Free(n)",
DiskColumn::Total => "Total(t)",
DiskColumn::UsedPercent => "Used%(p)",
DiskColumn::FreePercent => "Free%",
DiskColumn::IoRead => "R/s(r)",
DiskColumn::IoWrite => "W/s(w)",
}
.into()
}
}
impl DataToCell<DiskWidgetColumn> for DiskWidgetData {
impl DataToCell<DiskColumn> for DiskWidgetData {
fn to_cell(
&self, column: &DiskWidgetColumn, _calculated_width: NonZeroU16,
&self, column: &DiskColumn, _calculated_width: NonZeroU16,
) -> Option<Cow<'static, str>> {
fn percent_string(value: Option<f64>) -> Cow<'static, str> {
match value {
Some(val) => format!("{val:.1}%").into(),
None => "N/A".into(),
}
}
let text = match column {
DiskWidgetColumn::Disk => self.name.clone(),
DiskWidgetColumn::Mount => self.mount_point.clone(),
DiskWidgetColumn::Used => self.used_space(),
DiskWidgetColumn::Free => self.free_space(),
DiskWidgetColumn::UsedPercent => self.used_percent_string(),
DiskWidgetColumn::FreePercent => self.free_percent_string(),
DiskWidgetColumn::Total => self.total_space(),
DiskWidgetColumn::IoRead => self.io_read.clone(),
DiskWidgetColumn::IoWrite => self.io_write.clone(),
DiskColumn::Disk => self.name.clone(),
DiskColumn::Mount => self.mount_point.clone(),
DiskColumn::Used => self.used_space(),
DiskColumn::Free => self.free_space(),
DiskColumn::UsedPercent => percent_string(self.used_percent()),
DiskColumn::FreePercent => percent_string(self.free_percent()),
DiskColumn::Total => self.total_space(),
DiskColumn::IoRead => self.io_read.clone(),
DiskColumn::IoWrite => self.io_write.clone(),
};
Some(text)
}
fn column_widths<C: DataTableColumn<DiskWidgetColumn>>(
data: &[Self], _columns: &[C],
) -> Vec<u16>
fn column_widths<C: DataTableColumn<DiskColumn>>(data: &[Self], _columns: &[C]) -> Vec<u16>
where
Self: Sized,
{
@ -159,63 +199,85 @@ impl DataToCell<DiskWidgetColumn> for DiskWidgetData {
}
pub struct DiskTableWidget {
pub table: SortDataTable<DiskWidgetData, DiskWidgetColumn>,
pub table: SortDataTable<DiskWidgetData, DiskColumn>,
pub force_update_data: bool,
}
impl SortsRow for DiskWidgetColumn {
impl SortsRow for DiskColumn {
type DataType = DiskWidgetData;
fn sort_data(&self, data: &mut [Self::DataType], descending: bool) {
match self {
DiskWidgetColumn::Disk => {
DiskColumn::Disk => {
data.sort_by(|a, b| sort_partial_fn(descending)(&a.name, &b.name));
}
DiskWidgetColumn::Mount => {
DiskColumn::Mount => {
data.sort_by(|a, b| sort_partial_fn(descending)(&a.mount_point, &b.mount_point));
}
DiskWidgetColumn::Used => {
DiskColumn::Used => {
data.sort_by(|a, b| sort_partial_fn(descending)(&a.used_bytes, &b.used_bytes));
}
DiskWidgetColumn::UsedPercent => {
DiskColumn::UsedPercent => {
data.sort_by(|a, b| {
sort_partial_fn(descending)(&a.used_percent(), &b.used_percent())
});
}
DiskWidgetColumn::Free => {
DiskColumn::Free => {
data.sort_by(|a, b| sort_partial_fn(descending)(&a.free_bytes, &b.free_bytes));
}
DiskWidgetColumn::FreePercent => {
DiskColumn::FreePercent => {
data.sort_by(|a, b| {
sort_partial_fn(descending)(&a.free_percent(), &b.free_percent())
});
}
DiskWidgetColumn::Total => {
DiskColumn::Total => {
data.sort_by(|a, b| sort_partial_fn(descending)(&a.total_bytes, &b.total_bytes));
}
DiskWidgetColumn::IoRead => {
DiskColumn::IoRead => {
data.sort_by(|a, b| sort_partial_fn(descending)(&a.io_read, &b.io_read));
}
DiskWidgetColumn::IoWrite => {
DiskColumn::IoWrite => {
data.sort_by(|a, b| sort_partial_fn(descending)(&a.io_write, &b.io_write));
}
}
}
}
impl DiskTableWidget {
pub fn new(config: &AppConfigFields, palette: &ColourPalette) -> Self {
let columns = [
SortColumn::soft(DiskWidgetColumn::Disk, Some(0.2)),
SortColumn::soft(DiskWidgetColumn::Mount, Some(0.2)),
SortColumn::hard(DiskWidgetColumn::Used, 8).default_descending(),
SortColumn::hard(DiskWidgetColumn::Free, 8).default_descending(),
SortColumn::hard(DiskWidgetColumn::Total, 9).default_descending(),
SortColumn::hard(DiskWidgetColumn::UsedPercent, 9).default_descending(),
SortColumn::hard(DiskWidgetColumn::IoRead, 10).default_descending(),
SortColumn::hard(DiskWidgetColumn::IoWrite, 11).default_descending(),
];
const fn create_column(column_type: &DiskColumn) -> SortColumn<DiskColumn> {
match column_type {
DiskColumn::Disk => SortColumn::soft(DiskColumn::Disk, Some(0.2)),
DiskColumn::Mount => SortColumn::soft(DiskColumn::Mount, Some(0.2)),
DiskColumn::Used => SortColumn::hard(DiskColumn::Used, 8).default_descending(),
DiskColumn::Free => SortColumn::hard(DiskColumn::Free, 8).default_descending(),
DiskColumn::Total => SortColumn::hard(DiskColumn::Total, 9).default_descending(),
DiskColumn::UsedPercent => {
SortColumn::hard(DiskColumn::UsedPercent, 9).default_descending()
}
DiskColumn::FreePercent => {
SortColumn::hard(DiskColumn::FreePercent, 9).default_descending()
}
DiskColumn::IoRead => SortColumn::hard(DiskColumn::IoRead, 10).default_descending(),
DiskColumn::IoWrite => SortColumn::hard(DiskColumn::IoWrite, 11).default_descending(),
}
}
const fn default_disk_columns() -> [SortColumn<DiskColumn>; 8] {
[
create_column(&DiskColumn::Disk),
create_column(&DiskColumn::Mount),
create_column(&DiskColumn::Used),
create_column(&DiskColumn::Free),
create_column(&DiskColumn::Total),
create_column(&DiskColumn::UsedPercent),
create_column(&DiskColumn::IoRead),
create_column(&DiskColumn::IoWrite),
]
}
impl DiskTableWidget {
pub fn new(
config: &AppConfigFields, palette: &ColourPalette, columns: Option<&[DiskColumn]>,
) -> Self {
let props = SortDataTableProps {
inner: DataTableProps {
title: Some(" Disks ".into()),
@ -231,9 +293,18 @@ impl DiskTableWidget {
let styling = DataTableStyling::from_palette(palette);
Self {
table: SortDataTable::new_sortable(columns, props, styling),
force_update_data: false,
match columns {
Some(columns) => {
let columns = columns.iter().map(create_column).collect::<Vec<_>>();
Self {
table: SortDataTable::new_sortable(columns, props, styling),
force_update_data: false,
}
}
None => Self {
table: SortDataTable::new_sortable(default_disk_columns(), props, styling),
force_update_data: false,
},
}
}

View file

@ -94,28 +94,14 @@ impl ColumnHeader for ProcColumn {
fn header(&self) -> Cow<'static, str> {
match self {
ProcColumn::CpuPercent => "CPU%(c)",
ProcColumn::MemValue => "Mem(m)",
ProcColumn::MemPercent => "Mem%(m)",
ProcColumn::Pid => "PID(p)",
ProcColumn::Count => "Count",
ProcColumn::Name => "Name(n)",
ProcColumn::Command => "Command(n)",
ProcColumn::ReadPerSecond => "R/s",
ProcColumn::WritePerSecond => "W/s",
ProcColumn::TotalRead => "T.Read",
ProcColumn::TotalWrite => "T.Write",
ProcColumn::State => "State",
ProcColumn::User => "User",
ProcColumn::Time => "Time",
#[cfg(feature = "gpu")]
ProcColumn::GpuMemValue => "GMem",
#[cfg(feature = "gpu")]
ProcColumn::GpuMemPercent => "GMem%",
#[cfg(feature = "gpu")]
ProcColumn::GpuUtilPercent => "GPU%",
ProcColumn::CpuPercent => "CPU%(c)".into(),
ProcColumn::MemValue => "Mem(m)".into(),
ProcColumn::MemPercent => "Mem%(m)".into(),
ProcColumn::Pid => "PID(p)".into(),
ProcColumn::Name => "Name(n)".into(),
ProcColumn::Command => "Command(n)".into(),
_ => self.text(),
}
.into()
}
}
@ -214,7 +200,9 @@ impl<'de> Deserialize<'de> for ProcColumn {
"gmem" | "gmem%" => Ok(ProcColumn::GpuMemPercent),
#[cfg(feature = "gpu")]
"gpu%" => Ok(ProcColumn::GpuUtilPercent),
_ => Err(serde::de::Error::custom("doesn't match any column type")),
_ => Err(serde::de::Error::custom(
"doesn't match any process column name",
)),
}
}
}

View file

@ -131,3 +131,11 @@ fn test_invalid_process_column() {
.failure()
.stderr(predicate::str::contains("doesn't match"));
}
#[test]
fn test_invalid_disk_column() {
btm_command(&["-C", "./tests/invalid_configs/invalid_disk_column.toml"])
.assert()
.failure()
.stderr(predicate::str::contains("doesn't match"));
}

View file

@ -0,0 +1,2 @@
[disk]
columns = ["disk", "fake"]

View file

@ -1,4 +1,5 @@
#:schema none
# Adding this to avoid a warning from some schema linters
[styles]
theme = "gruvbox"