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 <dev@cjhtsang.ca>
This commit is contained in:
ChrisJr404
2026-05-08 00:31:08 -04:00
committed by GitHub
parent a45a66278b
commit 1888dbb270
14 changed files with 203 additions and 36 deletions
+1
View File
@@ -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
@@ -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
```
+4
View File
@@ -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
+11
View File
@@ -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": [
+4
View File
@@ -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
+8
View File
@@ -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 {
+11
View File
@@ -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<String>,
#[arg(
short = 'g',
long,
+25
View File
@@ -14,6 +14,10 @@ pub(crate) struct ProcessesConfig {
#[serde(default)]
pub columns: Vec<ProcColumn>,
/// The default sort column.
#[serde(default)]
pub default_sort: Option<ProcColumn>,
/// Whether to get process child threads.
pub get_threads: Option<bool>,
}
@@ -78,6 +82,27 @@ mod test {
toml_edit::de::from_str::<ProcessesConfig>(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::<ProcessesConfig>(config).expect_err("Should error out!");
}
#[test]
fn valid_process_column_config_2() {
let config = r#"columns = ["Twrite", "T.Write"]"#;
+67 -9
View File
@@ -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<ProcColumn>,
}
/// A hacky workaround for now.
@@ -409,18 +410,26 @@ impl ProcWidgetState {
})
.collect::<IndexSet<_>>();
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![
+34 -27
View File
@@ -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<Self> {
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<D>(deserializer: D) -> Result<Self, D::Error>
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"))
}
}
+11
View File
@@ -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"));
}
+5
View File
@@ -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"]);
}
@@ -0,0 +1,2 @@
[processes]
default_sort = "soup"
@@ -0,0 +1,2 @@
[processes]
default_sort = "mem"