diff --git a/CHANGELOG.md b/CHANGELOG.md index b5afd9b4..1b61fd97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/sample_configs/default_config.toml b/sample_configs/default_config.toml index 41998d1f..dae0e60f 100644 --- a/sample_configs/default_config.toml +++ b/sample_configs/default_config.toml @@ -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" diff --git a/schema/nightly/bottom.json b/schema/nightly/bottom.json index 68e04f00..47ade4e9 100644 --- a/schema/nightly/bottom.json +++ b/schema/nightly/bottom.json @@ -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": [ diff --git a/scripts/schema/validator.py b/scripts/schema/validator.py index 94060f6e..d0de01da 100644 --- a/scripts/schema/validator.py +++ b/scripts/schema/validator.py @@ -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: diff --git a/src/app/data_farmer.rs b/src/app/data_farmer.rs index 47d97ba8..0265aeb7 100644 --- a/src/app/data_farmer.rs +++ b/src/app/data_farmer.rs @@ -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), ); } } diff --git a/src/canvas/components/data_table/sortable.rs b/src/canvas/components/data_table/sortable.rs index 659fc939..67a07929 100644 --- a/src/canvas/components/data_table/sortable.rs +++ b/src/canvas/components/data_table/sortable.rs @@ -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) -> Self { + pub const fn soft(inner: T, max_percentage: Option) -> 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 } diff --git a/src/constants.rs b/src/constants.rs index 3e300744..ad3c6ef8 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -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" diff --git a/src/data_conversion.rs b/src/data_conversion.rs index 25df88b5..acb65107 100644 --- a/src/data_conversion.rs +++ b/src/data_conversion.rs @@ -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) } } diff --git a/src/main.rs b/src/main.rs index 26a89ffb..55eba7e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(); diff --git a/src/options.rs b/src/options.rs index a0d3119b..1692d24a 100644 --- a/src/options.rs +++ b/src/options.rs @@ -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 => { diff --git a/src/options/config/cpu.rs b/src/options/config/cpu.rs index 672b6016..b6dcc63d 100644 --- a/src/options/config/cpu.rs +++ b/src/options/config/cpu.rs @@ -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)] diff --git a/src/options/config/disk.rs b/src/options/config/disk.rs index bbc113bd..a8b9962e 100644 --- a/src/options/config/disk.rs +++ b/src/options/config/disk.rs @@ -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, + pub(crate) name_filter: Option, /// A filter over the mount names. - pub mount_filter: Option, + pub(crate) mount_filter: Option, + + /// A list of disk widget columns. + #[serde(default)] + pub(crate) columns: Vec, // 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::(config).expect("Should succeed!"); + } + + #[test] + fn bad_disk_column_settings() { + let config = r#"columns = ["diskk"]"#; + toml_edit::de::from_str::(config).expect_err("Should error out!"); + } } diff --git a/src/options/config/network.rs b/src/options/config/network.rs index 4d98194c..da2bf18e 100644 --- a/src/options/config/network.rs +++ b/src/options/config/network.rs @@ -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, + pub(crate) interface_filter: Option, } diff --git a/src/options/config/process.rs b/src/options/config/process.rs index c44f3379..c6e9b4f1 100644 --- a/src/options/config/process.rs +++ b/src/options/config/process.rs @@ -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, + pub(crate) columns: Vec, // 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::(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!( diff --git a/src/options/config/temperature.rs b/src/options/config/temperature.rs index 2580a2ea..8ae57a96 100644 --- a/src/options/config/temperature.rs +++ b/src/options/config/temperature.rs @@ -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, + pub(crate) sensor_filter: Option, } diff --git a/src/widgets/cpu_graph.rs b/src/widgets/cpu_graph.rs index 3c3d17e0..93df6849 100644 --- a/src/widgets/cpu_graph.rs +++ b/src/widgets/cpu_graph.rs @@ -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, colours: &ColourPalette, ) -> Self { diff --git a/src/widgets/disk_table.rs b/src/widgets/disk_table.rs index 4c26c9d0..05891b94 100644 --- a/src/widgets/disk_table.rs +++ b/src/widgets/disk_table.rs @@ -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 { 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(deserializer: D) -> Result + 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 for DiskWidgetData { +impl DataToCell for DiskWidgetData { fn to_cell( - &self, column: &DiskWidgetColumn, _calculated_width: NonZeroU16, + &self, column: &DiskColumn, _calculated_width: NonZeroU16, ) -> Option> { + fn percent_string(value: Option) -> 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>( - data: &[Self], _columns: &[C], - ) -> Vec + fn column_widths>(data: &[Self], _columns: &[C]) -> Vec where Self: Sized, { @@ -159,63 +199,85 @@ impl DataToCell for DiskWidgetData { } pub struct DiskTableWidget { - pub table: SortDataTable, + pub table: SortDataTable, 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 { + 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; 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::>(); + 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, + }, } } diff --git a/src/widgets/process_table/process_columns.rs b/src/widgets/process_table/process_columns.rs index d920ad1a..aa067a1d 100644 --- a/src/widgets/process_table/process_columns.rs +++ b/src/widgets/process_table/process_columns.rs @@ -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", + )), } } } diff --git a/tests/integration/invalid_config_tests.rs b/tests/integration/invalid_config_tests.rs index 92a67eac..b9182f81 100644 --- a/tests/integration/invalid_config_tests.rs +++ b/tests/integration/invalid_config_tests.rs @@ -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")); +} diff --git a/tests/invalid_configs/invalid_disk_column.toml b/tests/invalid_configs/invalid_disk_column.toml new file mode 100644 index 00000000..9e8d898e --- /dev/null +++ b/tests/invalid_configs/invalid_disk_column.toml @@ -0,0 +1,2 @@ +[disk] +columns = ["disk", "fake"] diff --git a/tests/valid_configs/theme.toml b/tests/valid_configs/theme.toml index ca83505c..3455fa7e 100644 --- a/tests/valid_configs/theme.toml +++ b/tests/valid_configs/theme.toml @@ -1,4 +1,5 @@ #:schema none +# Adding this to avoid a warning from some schema linters [styles] theme = "gruvbox"