mirror of
https://github.com/ClementTsang/bottom.git
synced 2026-05-15 03:11:33 +00:00
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:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]"#;
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user