From 1888dbb270d965b1f373dcc32961ad37b4528ebe Mon Sep 17 00:00:00 2001 From: ChrisJr404 Date: Fri, 8 May 2026 00:31:08 -0400 Subject: [PATCH] feature: configurable default sort column for the process widget (#2053) ## Description You can now tell bottom which column the process widget should be sorted by at startup, instead of always falling back to CPU%. It's settable in two places: a `default_sort` field under `[processes]` in the config file, and a `--process_default_sort` CLI flag. The flag accepts the same column-name aliases that already work in `[processes].columns` (e.g. `cpu%`, `mem`, `pid`, `name`, `read`, `t.write`, etc.), and the CLI flag wins over the config setting. ```toml [processes] default_sort = "mem" ``` ``` btm --process_default_sort mem ``` This piggybacks on the same wiring that #2003 added for the disk and temperature widgets, just routed through the existing `ProcTableConfig` so the widget initializer can pick a non-default sort index without touching any of the runtime sort code. A few small things worth flagging: - If the configured column is not actually present in the user's `columns` list, the widget falls back to the existing default behaviour (CPU% in normal/grouped mode, PID in tree mode) instead of silently snapping to column 0. That keeps misconfiguration predictable. - The deserializer for `ProcColumn` was refactored to share its parsing with the CLI's value parser, so config-file and CLI accept the exact same aliases. The set of accepted strings is unchanged from before. I checked the `# columns =` workaround floating around in #810 and on Stack Overflow; it changes layout but not the sort key, so it doesn't actually solve the original ask. ## Issue Closes: #810 ## Testing _If this change affects the program, please also indicate which platforms were tested:_ - [ ] _Windows_ - [ ] _macOS (specify version below)_ - [x] _Linux (Kali, kernel 6.19)_ - [ ] _Other (specify below)_ Added unit tests for `ProcWidgetState::new` covering the happy path (sort index actually lands on the configured column) and the fallback path (configured column not in the column list). Also added valid + invalid config integration tests under `tests/valid_configs/` and `tests/invalid_configs/`, matching the pattern used for the disk/temp default-sort tests. `cargo test`, `cargo clippy --all -- -D warnings`, and `cargo fmt --check` are all clean. Manual smoke test: ran `btm --process_default_sort=mem` and `btm --process_default_sort=garbage`. The first starts the widget sorted by Mem%, the second exits early with `'garbage' is not a valid process column for --process_default_sort` from the args layer. Also ran `btm -C tests/valid_configs/proc_default_sort.toml` to confirm the config-file path works the same way. ## Checklist _Ensure **all** of these are met:_ - [x] _If this PR adds or changes a dependency, please justify this in the description_ (no new deps) - [x] _If this is a code change, areas your change affects have been linted using (`cargo fmt`)_ - [x] _If this is a code change, your changes pass `cargo clippy --all -- -D warnings`_ - [x] _If this is a code change, new tests were added if relevant_ - [x] _If this is a code change, your changes pass `cargo test`_ - [x] _The change has been tested to work (see above) and doesn't appear to break other things_ - [x] _Documentation has been updated if needed (`README.md`, help menu, docs, configs, etc.)_ - [x] _There are no merge conflicts_ - [x] _You have reviewed your changes first_ - [x] _The pull request passes the provided CI pipeline_ ## Other The schema under `schema/nightly/bottom.json` was regenerated via `cargo run --features generate_schema --bin schema`; the only diff is the new `default_sort` entry on `ProcessesConfig`, mirroring what's already there on `DiskConfig` and `TemperatureConfig`. --------- Co-authored-by: ClementTsang --- CHANGELOG.md | 1 + .../configuration/config-file/processes.md | 18 +++++ sample_configs/default_config.toml | 4 + schema/nightly/bottom.json | 11 +++ src/constants.rs | 4 + src/options.rs | 8 ++ src/options/args.rs | 11 +++ src/options/config/process.rs | 25 ++++++ src/widgets/process_table.rs | 76 ++++++++++++++++--- src/widgets/process_table/process_columns.rs | 61 ++++++++------- tests/integration/invalid_config_tests.rs | 11 +++ tests/integration/valid_config_tests.rs | 5 ++ .../invalid_proc_default_sort.toml | 2 + tests/valid_configs/proc_default_sort.toml | 2 + 14 files changed, 203 insertions(+), 36 deletions(-) create mode 100644 tests/invalid_configs/invalid_proc_default_sort.toml create mode 100644 tests/valid_configs/proc_default_sort.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index dba26928..959f4148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ That said, these are more guidelines rather than hard rules, though the project - [#2045](https://github.com/ClementTsang/bottom/pull/2045): Add support for showing a decimal place for CPU usage - [#2046](https://github.com/ClementTsang/bottom/pull/2046): Add a `show_table_scroll_bar` config option to show a scroll bar on table widgets. - [#2048](https://github.com/ClementTsang/bottom/pull/2048): Add support for a temperature graph to show sensor temperature change over time. +- [#2053](https://github.com/ClementTsang/bottom/pull/2053): Add a configurable default sort column for the process widget (`processes.default_sort` or `--process-default-sort`). ### Changes diff --git a/docs/content/configuration/config-file/processes.md b/docs/content/configuration/config-file/processes.md index abd319fc..fa9bcb70 100644 --- a/docs/content/configuration/config-file/processes.md +++ b/docs/content/configuration/config-file/processes.md @@ -17,3 +17,21 @@ You can configure which columns are shown by the process widget by setting the ` # Pick which columns you want to use in any order. columns = ["cpu%", "mem%", "pid", "name", "read", "write", "tread", "twrite", "state", "user", "time", "gmem%", "gpu%"] ``` + +## Default Sort Order + +By default, the process widget starts sorted by CPU usage. You can change the column it sorts by at startup: + +```toml +[processes] +default_sort = "mem" +``` + +Any of the column names accepted by `columns` work here (e.g. `"cpu%"`, `"mem"`, `"pid"`, `"name"`, `"read"`). If the +column you pick is not actually shown by the widget, the built-in default is used instead. + +The same setting is also exposed as a CLI flag, which takes precedence over the config file: + +``` +btm --process_default_sort mem +``` diff --git a/sample_configs/default_config.toml b/sample_configs/default_config.toml index 78032a7e..46c09154 100644 --- a/sample_configs/default_config.toml +++ b/sample_configs/default_config.toml @@ -141,6 +141,10 @@ # PID, Name, CPU%, Mem%, R/s, W/s, T.Read, T.Write, User, State, Time, GMem%, GPU%, Nice, Priority #columns = ["PID", "Name", "CPU%", "Mem%", "Virt", "R/s", "W/s", "T.Read", "T.Write", "User", "State", "GMem%", "GPU%", "Priority"] +# The default sort column when bottom starts. Accepts any of the column names above. +# If unset, defaults to CPU%. +#default_sort = "CPU%" + # Gather process child thread information #get_threads = false diff --git a/schema/nightly/bottom.json b/schema/nightly/bottom.json index 06dd36a4..9e0bbbde 100644 --- a/schema/nightly/bottom.json +++ b/schema/nightly/bottom.json @@ -868,6 +868,17 @@ "$ref": "#/$defs/ProcColumn" } }, + "default_sort": { + "description": "The default sort column.", + "anyOf": [ + { + "$ref": "#/$defs/ProcColumn" + }, + { + "type": "null" + } + ] + }, "get_threads": { "description": "Whether to get process child threads.", "type": [ diff --git a/src/constants.rs b/src/constants.rs index b8e23540..fc4071ae 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -389,6 +389,10 @@ pub(crate) const CONFIG_TEXT: &str = r#"# This is a default config file for bott # PID, Name, CPU%, Mem%, R/s, W/s, T.Read, T.Write, User, State, Time, GMem%, GPU%, Nice, Priority #columns = ["PID", "Name", "CPU%", "Mem%", "Virt", "R/s", "W/s", "T.Read", "T.Write", "User", "State", "GMem%", "GPU%", "Priority"] +# The default sort column when bottom starts. Accepts any of the column names above. +# If unset, defaults to CPU%. +#default_sort = "CPU%" + # Gather process child thread information #get_threads = false diff --git a/src/options.rs b/src/options.rs index 1c43363b..5e50a245 100644 --- a/src/options.rs +++ b/src/options.rs @@ -379,6 +379,13 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL temperature_legend_position, }; + let process_default_sort = match &args.process.process_default_sort { + Some(name) => Some(ProcColumn::parse_column_name(name).ok_or_else(|| { + anyhow::anyhow!("'{name}' is not a valid process column for --process_default_sort") + })?), + None => config.processes.as_ref().and_then(|cfg| cfg.default_sort), + }; + let ts_config = TimeseriesConfig { time_interval: app_config_fields.time_interval, retention_ms: app_config_fields.retention_ms, @@ -392,6 +399,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL is_use_regex, show_memory_as_values: process_memory_as_value, is_command: is_default_command, + default_sort: process_default_sort, }; for row in &widget_layout.rows { diff --git a/src/options/args.rs b/src/options/args.rs index 09d6dff2..43f05d08 100644 --- a/src/options/args.rs +++ b/src/options/args.rs @@ -343,6 +343,17 @@ pub struct ProcessArgs { )] pub get_threads: bool, + #[arg( + long, + value_name = "COLUMN", + help = "Sets the default sort column for the process widget.", + long_help = "Sets the default sort column for the process widget. Accepts any of the \ + valid process column names (e.g. \"cpu%\", \"mem\", \"pid\", \"name\"). \ + Overrides the [processes] default_sort setting in the config file.", + alias = "process-default-sort" + )] + pub process_default_sort: Option, + #[arg( short = 'g', long, diff --git a/src/options/config/process.rs b/src/options/config/process.rs index 0dfa9bcf..ce4a9089 100644 --- a/src/options/config/process.rs +++ b/src/options/config/process.rs @@ -14,6 +14,10 @@ pub(crate) struct ProcessesConfig { #[serde(default)] pub columns: Vec, + /// The default sort column. + #[serde(default)] + pub default_sort: Option, + /// Whether to get process child threads. pub get_threads: Option, } @@ -78,6 +82,27 @@ mod test { toml_edit::de::from_str::(config).expect_err("Should error out!"); } + #[test] + fn valid_default_sort_config() { + let config = r#"default_sort = "mem""#; + let generated: ProcessesConfig = toml_edit::de::from_str(config).unwrap(); + assert_eq!(generated.default_sort, Some(ProcColumn::MemPercent)); + + let config = r#"default_sort = "PID""#; + let generated: ProcessesConfig = toml_edit::de::from_str(config).unwrap(); + assert_eq!(generated.default_sort, Some(ProcColumn::Pid)); + + let config = ""; + let generated: ProcessesConfig = toml_edit::de::from_str(config).unwrap(); + assert_eq!(generated.default_sort, None); + } + + #[test] + fn invalid_default_sort_config() { + let config = r#"default_sort = "soup""#; + toml_edit::de::from_str::(config).expect_err("Should error out!"); + } + #[test] fn valid_process_column_config_2() { let config = r#"columns = ["Twrite", "T.Write"]"#; diff --git a/src/widgets/process_table.rs b/src/widgets/process_table.rs index 331da4c5..e2bcd074 100644 --- a/src/widgets/process_table.rs +++ b/src/widgets/process_table.rs @@ -174,6 +174,7 @@ pub struct ProcTableConfig { pub is_use_regex: bool, pub show_memory_as_values: bool, pub is_command: bool, + pub default_sort: Option, } /// A hacky workaround for now. @@ -409,18 +410,26 @@ impl ProcWidgetState { }) .collect::>(); - let (default_sort_index, default_sort_order) = - if matches!(mode, ProcWidgetMode::Tree { .. }) { - if let Some(index) = column_mapping.get_index_of(&ProcWidgetColumn::PidOrCount) { - (index, columns[index].default_order) - } else { - (0, columns[0].default_order) - } - } else if let Some(index) = column_mapping.get_index_of(&ProcWidgetColumn::Cpu) { + let configured_default_sort = table_config.default_sort.and_then(|c| { + let widget_col = ProcWidgetColumn::from(&c); + column_mapping + .get_index_of(&widget_col) + .map(|index| (index, columns[index].default_order)) + }); + + let (default_sort_index, default_sort_order) = if let Some(pair) = configured_default_sort { + pair + } else if matches!(mode, ProcWidgetMode::Tree { .. }) { + if let Some(index) = column_mapping.get_index_of(&ProcWidgetColumn::PidOrCount) { (index, columns[index].default_order) } else { (0, columns[0].default_order) - }; + } + } else if let Some(index) = column_mapping.get_index_of(&ProcWidgetColumn::Cpu) { + (index, columns[index].default_order) + } else { + (0, columns[0].default_order) + }; let sort_table = Self::new_sort_table(config, colours); let table = Self::new_process_table( @@ -1286,6 +1295,55 @@ mod test { init_state(ProcTableConfig::default(), columns) } + #[test] + fn default_sort_honoured() { + let init_columns = [ + ProcWidgetColumn::PidOrCount, + ProcWidgetColumn::ProcNameOrCommand, + ProcWidgetColumn::Cpu, + ProcWidgetColumn::Mem, + ]; + + let state_default = init_default_state(&init_columns); + assert_eq!( + state_default.table.sort_index(), + 2, + "default sort should be CPU (index 2 in init_columns)" + ); + + let table_config = ProcTableConfig { + default_sort: Some(ProcColumn::MemPercent), + ..Default::default() + }; + let state_mem = init_state(table_config, &init_columns); + assert_eq!(state_mem.table.sort_index(), 3); + + let table_config = ProcTableConfig { + default_sort: Some(ProcColumn::Pid), + ..Default::default() + }; + let state_pid = init_state(table_config, &init_columns); + assert_eq!(state_pid.table.sort_index(), 0); + } + + #[test] + fn default_sort_falls_back_when_column_absent() { + // `default_sort` points at a column the user didn't include. We should + // fall back to the built-in default rather than panic or pick column 0. + let init_columns = [ + ProcWidgetColumn::PidOrCount, + ProcWidgetColumn::ProcNameOrCommand, + ProcWidgetColumn::Cpu, + ]; + + let table_config = ProcTableConfig { + default_sort: Some(ProcColumn::MemPercent), + ..Default::default() + }; + let state = init_state(table_config, &init_columns); + assert_eq!(state.table.sort_index(), 2); + } + #[test] fn custom_columns() { let init_columns = vec![ diff --git a/src/widgets/process_table/process_columns.rs b/src/widgets/process_table/process_columns.rs index 1a3a2003..e1463a04 100644 --- a/src/widgets/process_table/process_columns.rs +++ b/src/widgets/process_table/process_columns.rs @@ -201,38 +201,45 @@ impl SortsRow for ProcColumn { } } +impl ProcColumn { + /// Parse a column name, case-insensitively, accepting any of the aliases + /// recognized in the config file. + pub fn parse_column_name(name: &str) -> Option { + match name.to_lowercase().as_str() { + "cpu%" => Some(ProcColumn::CpuPercent), + "mem" | "mem%" => Some(ProcColumn::MemPercent), + "virt" | "virtual" | "virtmem" | "virtual memory" => Some(ProcColumn::VirtualMem), + "pid" => Some(ProcColumn::Pid), + "count" => Some(ProcColumn::Count), + "name" => Some(ProcColumn::Name), + "command" => Some(ProcColumn::Command), + "read" | "r/s" | "rps" => Some(ProcColumn::ReadPerSecond), + "write" | "w/s" | "wps" => Some(ProcColumn::WritePerSecond), + "tread" | "t.read" => Some(ProcColumn::TotalRead), + "twrite" | "t.write" => Some(ProcColumn::TotalWrite), + "state" => Some(ProcColumn::State), + "user" => Some(ProcColumn::User), + "time" => Some(ProcColumn::Time), + #[cfg(unix)] + "nice" => Some(ProcColumn::Nice), + "priority" => Some(ProcColumn::Priority), + #[cfg(feature = "gpu")] + "gmem" | "gmem%" => Some(ProcColumn::GpuMemPercent), + #[cfg(feature = "gpu")] + "gpu%" => Some(ProcColumn::GpuUtilPercent), + _ => None, + } + } +} + impl<'de> Deserialize<'de> for ProcColumn { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { - let value = String::deserialize(deserializer)?.to_lowercase(); - match value.as_str() { - "cpu%" => Ok(ProcColumn::CpuPercent), - "mem" | "mem%" => Ok(ProcColumn::MemPercent), - "virt" | "virtual" | "virtmem" | "virtual memory" => Ok(ProcColumn::VirtualMem), - "pid" => Ok(ProcColumn::Pid), - "count" => Ok(ProcColumn::Count), - "name" => Ok(ProcColumn::Name), - "command" => Ok(ProcColumn::Command), - "read" | "r/s" | "rps" => Ok(ProcColumn::ReadPerSecond), - "write" | "w/s" | "wps" => Ok(ProcColumn::WritePerSecond), - "tread" | "t.read" => Ok(ProcColumn::TotalRead), - "twrite" | "t.write" => Ok(ProcColumn::TotalWrite), - "state" => Ok(ProcColumn::State), - "user" => Ok(ProcColumn::User), - "time" => Ok(ProcColumn::Time), - #[cfg(unix)] - "nice" => Ok(ProcColumn::Nice), - "priority" => Ok(ProcColumn::Priority), - #[cfg(feature = "gpu")] - "gmem" | "gmem%" => Ok(ProcColumn::GpuMemPercent), - #[cfg(feature = "gpu")] - "gpu%" => Ok(ProcColumn::GpuUtilPercent), - _ => Err(serde::de::Error::custom( - "doesn't match any process column name", - )), - } + let value = String::deserialize(deserializer)?; + ProcColumn::parse_column_name(&value) + .ok_or_else(|| 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 4f92f690..09ab04dd 100644 --- a/tests/integration/invalid_config_tests.rs +++ b/tests/integration/invalid_config_tests.rs @@ -158,3 +158,14 @@ fn test_invalid_temp_disk_default_sorts() { .failure() .stderr(predicate::str::contains("doesn't match")); } + +#[test] +fn test_invalid_proc_default_sort() { + btm_command(&[ + "-C", + "./tests/invalid_configs/invalid_proc_default_sort.toml", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("doesn't match")); +} diff --git a/tests/integration/valid_config_tests.rs b/tests/integration/valid_config_tests.rs index 9021617f..fb693de2 100644 --- a/tests/integration/valid_config_tests.rs +++ b/tests/integration/valid_config_tests.rs @@ -200,3 +200,8 @@ fn test_linux_only() { fn test_temp_disk_sort_columns() { run_and_kill(&["-C", "./tests/valid_configs/temp_disk_sort_columns.toml"]); } + +#[test] +fn test_proc_default_sort() { + run_and_kill(&["-C", "./tests/valid_configs/proc_default_sort.toml"]); +} diff --git a/tests/invalid_configs/invalid_proc_default_sort.toml b/tests/invalid_configs/invalid_proc_default_sort.toml new file mode 100644 index 00000000..698f453d --- /dev/null +++ b/tests/invalid_configs/invalid_proc_default_sort.toml @@ -0,0 +1,2 @@ +[processes] +default_sort = "soup" diff --git a/tests/valid_configs/proc_default_sort.toml b/tests/valid_configs/proc_default_sort.toml new file mode 100644 index 00000000..b99f9673 --- /dev/null +++ b/tests/valid_configs/proc_default_sort.toml @@ -0,0 +1,2 @@ +[processes] +default_sort = "mem"