feature: add support for threads in linux (#1793)

* feature: add support for threads in linux

* bump version too

* only enable for linux for now

* thread some things around

* update changelog

* add highlighting support

* fmt and run schema

* how did this get added

* hmmm cfg in if seems to not work

* fix updated fields

* fixes

* revert uptime rename

* some cleanup

* fix doc

* oop
This commit is contained in:
Clement Tsang 2025-08-17 03:07:50 -04:00 committed by GitHub
parent 2578f20ce5
commit 3ff7977e6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 333 additions and 104 deletions

View File

@ -20,6 +20,12 @@ That said, these are more guidelines rather than hardset rules, though the proje
---
## [0.12.0] - Unreleased
### Features
- [#1793](https://github.com/ClementTsang/bottom/pull/1793): Add support for threads in Linux.
## [0.11.1] - 2025-08-15
### Bug Fixes

2
Cargo.lock generated
View File

@ -145,7 +145,7 @@ checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "bottom"
version = "0.11.1"
version = "0.12.0-nightly"
dependencies = [
"anyhow",
"assert_cmd",

View File

@ -1,6 +1,6 @@
[package]
name = "bottom"
version = "0.11.1"
version = "0.12.0-nightly"
repository = "https://github.com/ClementTsang/bottom"
license = "MIT"
description = "A customizable cross-platform graphical process/system monitor for the terminal. Supports Linux, macOS, and Windows."

View File

@ -207,7 +207,10 @@
"properties": {
"columns": {
"description": "A list of disk widget columns.",
"type": "array",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/$defs/DiskColumn"
}
@ -751,6 +754,13 @@
"items": {
"$ref": "#/$defs/ProcColumn"
}
},
"get_threads": {
"description": "Whether to get process child threads.",
"type": [
"boolean",
"null"
]
}
}
},

View File

@ -42,6 +42,7 @@ pub struct AppConfigFields {
pub show_average_cpu: bool, // TODO: Unify this in CPU options
pub use_current_cpu_total: bool,
pub unnormalized_cpu: bool,
pub get_process_threads: bool,
pub use_basic_mode: bool,
pub default_time_value: u64,
pub time_interval: u64,

View File

@ -166,7 +166,7 @@ mod test {
}
impl DataToCell<&'static str> for TestType {
fn to_cell(
fn to_cell_text(
&self, _column: &&'static str, _calculated_width: NonZeroU16,
) -> Option<Cow<'static, str>> {
None

View File

@ -10,15 +10,29 @@ where
H: ColumnHeader,
{
/// Given data, a column, and its corresponding width, return the string in
/// the cell that will be displayed in the
/// [`DataTable`](super::DataTable).
fn to_cell(&self, column: &H, calculated_width: NonZeroU16) -> Option<Cow<'static, str>>;
/// the cell that will be displayed in the [`super::DataTable`].
fn to_cell_text(&self, column: &H, calculated_width: NonZeroU16) -> Option<Cow<'static, str>>;
/// Given a column, how to style a cell if one needs to override the default styling.
///
/// By default this just returns [`None`], deferring to the row or table styling.
#[expect(
unused_variables,
reason = "The default implementation just returns `None`."
)]
fn style_cell(&self, column: &H, painter: &Painter) -> Option<tui::style::Style> {
None
}
/// Apply styling to the generated [`Row`] of cells.
///
/// The default implementation just returns the `row` that is passed in.
#[inline(always)]
fn style_row<'a>(&self, row: Row<'a>, _painter: &Painter) -> Row<'a> {
#[expect(
unused_variables,
reason = "The default implementation just returns an unstyled row."
)]
fn style_row<'a>(&self, row: Row<'a>, painter: &Painter) -> Row<'a> {
row
}

View File

@ -8,7 +8,7 @@ use tui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
text::{Line, Span, Text},
widgets::{Block, Row, Table},
widgets::{Block, Cell, Row, Table},
};
use super::{
@ -217,9 +217,17 @@ where
.iter()
.zip(&self.state.calculated_widths)
.filter_map(|(column, &width)| {
data_row
.to_cell(column.inner(), width)
.map(|content| truncate_to_text(&content, width.get()))
data_row.to_cell_text(column.inner(), width).map(|content| {
let content = truncate_to_text(&content, width.get());
if let Some(style) =
data_row.style_cell(column.inner(), painter)
{
Cell::new(content).style(style)
} else {
Cell::new(content)
}
})
}),
);

View File

@ -360,7 +360,7 @@ mod test {
}
impl DataToCell<ColumnType> for TestType {
fn to_cell(
fn to_cell_text(
&self, _column: &ColumnType, _calculated_width: NonZeroU16,
) -> Option<Cow<'static, str>> {
None

View File

@ -68,7 +68,7 @@ impl Painter {
}
/// Draws the process sort box.
/// - `widget_id` represents the widget ID of the process widget itself.an
/// - `widget_id` represents the widget ID of the process widget itself.
fn draw_processes_table(
&self, f: &mut Frame<'_>, app_state: &mut App, draw_loc: Rect, widget_id: u64,
) {

View File

@ -160,14 +160,13 @@ pub struct DataCollector {
unnormalized_cpu: bool,
use_current_cpu_total: bool,
show_average_cpu: bool,
get_process_threads: bool,
#[cfg(any(not(target_os = "linux"), feature = "battery"))]
last_list_collection_time: Instant,
#[cfg(any(not(target_os = "linux"), feature = "battery"))]
should_refresh_list: bool,
should_run_less_routine_tasks: bool,
#[cfg(target_os = "linux")]
pid_mapping: HashMap<Pid, processes::PrevProcDetails>,
prev_process_details: HashMap<Pid, processes::PrevProcDetails>,
#[cfg(target_os = "linux")]
prev_idle: f64,
#[cfg(target_os = "linux")]
@ -187,25 +186,26 @@ pub struct DataCollector {
gpus_total_mem: Option<u64>,
}
const LIST_REFRESH_TIME: Duration = Duration::from_secs(60);
const LESS_ROUTINE_TASK_TIME: Duration = Duration::from_secs(60);
impl DataCollector {
pub fn new(filters: DataFilters) -> Self {
// Initialize it to the past to force it to load on initialization.
let now = Instant::now();
let last_collection_time = now.checked_sub(LIST_REFRESH_TIME * 10).unwrap_or(now);
let last_collection_time = now.checked_sub(LESS_ROUTINE_TASK_TIME * 10).unwrap_or(now);
DataCollector {
data: Data::default(),
sys: SysinfoSource::default(),
#[cfg(target_os = "linux")]
pid_mapping: HashMap::default(),
prev_process_details: HashMap::default(),
#[cfg(target_os = "linux")]
prev_idle: 0_f64,
#[cfg(target_os = "linux")]
prev_non_idle: 0_f64,
use_current_cpu_total: false,
unnormalized_cpu: false,
get_process_threads: false,
last_collection_time,
total_rx: 0,
total_tx: 0,
@ -222,30 +222,27 @@ impl DataCollector {
gpu_pids: None,
#[cfg(feature = "gpu")]
gpus_total_mem: None,
#[cfg(any(not(target_os = "linux"), feature = "battery"))]
last_list_collection_time: last_collection_time,
#[cfg(any(not(target_os = "linux"), feature = "battery"))]
should_refresh_list: true,
should_run_less_routine_tasks: true,
}
}
/// Update the check for updating things like lists of batteries, etc.
/// Update the check for routine tasks like updating lists of batteries, cleanup, etc.
/// This is useful for things that we don't want to update all the time.
///
/// Note this should be set back to false if `self.last_list_collection_time` is updated.
#[inline]
#[cfg(any(not(target_os = "linux"), feature = "battery"))]
fn update_refresh_list_check(&mut self) {
fn run_less_routine_tasks(&mut self) {
if self
.data
.collection_time
.duration_since(self.last_list_collection_time)
> LIST_REFRESH_TIME
> LESS_ROUTINE_TASK_TIME
{
self.should_refresh_list = true;
self.should_run_less_routine_tasks = true;
}
if self.should_refresh_list {
if self.should_run_less_routine_tasks {
self.last_list_collection_time = self.data.collection_time;
}
}
@ -266,6 +263,10 @@ impl DataCollector {
self.show_average_cpu = show_average_cpu;
}
pub fn set_get_process_threads(&mut self, get_process_threads: bool) {
self.get_process_threads = get_process_threads;
}
/// Refresh sysinfo data. We use sysinfo for the following data:
/// - CPU usage
/// - Memory usage
@ -307,13 +308,13 @@ impl DataCollector {
// For Windows, sysinfo also handles the users list.
#[cfg(target_os = "windows")]
if self.should_refresh_list {
if self.should_run_less_routine_tasks {
self.sys.users.refresh();
}
}
if self.widgets_to_harvest.use_temp {
if self.should_refresh_list {
if self.should_run_less_routine_tasks {
self.sys.temps.refresh(true);
}
@ -324,7 +325,7 @@ impl DataCollector {
#[cfg(target_os = "windows")]
if self.widgets_to_harvest.use_disk {
if self.should_refresh_list {
if self.should_run_less_routine_tasks {
self.sys.disks.refresh(true);
}
@ -341,10 +342,7 @@ impl DataCollector {
pub fn update_data(&mut self) {
self.data.collection_time = Instant::now();
#[cfg(any(not(target_os = "linux"), feature = "battery"))]
{
self.update_refresh_list_check();
}
self.run_less_routine_tasks();
self.refresh_sysinfo_data();
@ -362,10 +360,8 @@ impl DataCollector {
self.update_network_usage();
self.update_disks();
#[cfg(any(not(target_os = "linux"), feature = "battery"))]
{
self.should_refresh_list = false;
}
// Make sure to run this to refresh the setting.
self.should_run_less_routine_tasks = false;
// Update times for future reference.
self.last_collection_time = self.data.collection_time;
@ -503,14 +499,14 @@ impl DataCollector {
///
/// If the battery manager is not initialized, it will attempt to initialize it if at least one battery is found.
///
/// This function also refreshes the list of batteries if `self.should_refresh_list` is true.
/// This function also refreshes the list of batteries if `self.should_run_less_routine_tasks` is true.
#[inline]
#[cfg(feature = "battery")]
fn update_batteries(&mut self) {
let battery_manager = match &self.battery_manager {
Some(manager) => {
// Also check if we need to refresh the list of batteries.
if self.should_refresh_list {
if self.should_run_less_routine_tasks {
let battery_list = manager
.batteries()
.map(|batteries| batteries.filter_map(Result::ok).collect::<Vec<_>>());

View File

@ -48,6 +48,36 @@ cfg_if! {
pub type Bytes = u64;
#[cfg(target_os = "linux")]
/// The process entry "type".
#[derive(Debug, Clone, Copy, Default)]
pub enum ProcessType {
/// A regular user process.
#[default]
Regular,
/// A kernel process.
///
/// TODO: Use <https://github.com/htop-dev/htop/commit/07496eafb0166aafd9c33a6a95e16bcbc64c34d4>?
Kernel,
/// A thread spawned by a regular user process.
ProcessThread,
}
#[cfg(target_os = "linux")]
impl ProcessType {
/// Returns `true` if this is a thread.
pub fn is_thread(&self) -> bool {
matches!(self, Self::ProcessThread)
}
/// Returns `true` if this is a kernel process.
pub fn is_kernel(&self) -> bool {
matches!(self, Self::Kernel)
}
}
#[derive(Debug, Clone, Default)]
pub struct ProcessHarvest {
/// The pid of the process.
@ -60,6 +90,8 @@ pub struct ProcessHarvest {
pub cpu_usage_percent: f32,
/// Memory usage as a percentage.
///
/// TODO: Maybe calculate this on usage? Store the total mem along with the vector of results.
pub mem_usage_percent: f32,
/// Memory usage as bytes.
@ -105,12 +137,18 @@ pub struct ProcessHarvest {
pub gpu_mem: u64,
/// Gpu memory usage as percentage.
///
/// TODO: Maybe calculate this on usage? Store the total GPU mem along with the vector of results.
#[cfg(feature = "gpu")]
pub gpu_mem_percent: f32,
/// Gpu utilization as a percentage.
#[cfg(feature = "gpu")]
pub gpu_util: u32,
/// The process entry "type".
#[cfg(target_os = "linux")]
pub process_type: ProcessType,
// TODO: Additional fields
// pub rss_kb: u64,
// pub virt_kb: u64,

View File

@ -9,14 +9,16 @@ use std::{
};
use concat_string::concat_string;
use hashbrown::HashSet;
use hashbrown::{HashMap, HashSet};
use process::*;
use sysinfo::ProcessStatus;
use super::{Pid, ProcessHarvest, UserTable, process_status_str};
use crate::collection::{DataCollector, error::CollectionResult};
use crate::collection::{DataCollector, error::CollectionResult, processes::ProcessType};
/// Maximum character length of a `/proc/<PID>/stat`` process name.
/// Maximum character length of a `/proc/<PID>/stat` process name (the length is 16,
/// but this includes a null terminator).
///
/// If it's equal or greater, then we instead refer to the command for the name.
const MAX_STAT_NAME_LEN: usize = 15;
@ -132,6 +134,7 @@ fn get_linux_cpu_usage(
fn read_proc(
prev_proc: &PrevProcDetails, process: Process, args: ReadProcArgs, user_table: &mut UserTable,
thread_parent: Option<Pid>,
) -> CollectionResult<(ProcessHarvest, u64)> {
let Process {
pid: _,
@ -147,7 +150,8 @@ fn read_proc(
cpu_fraction,
total_memory,
time_difference_in_secs,
uptime,
system_uptime,
get_process_threads: _,
} = args;
let process_state_char = stat.state;
@ -162,7 +166,13 @@ fn read_proc(
prev_proc.cpu_time,
use_current_cpu_total,
);
let parent_pid = Some(stat.ppid);
let (parent_pid, process_type) = if let Some(thread_parent) = thread_parent {
(Some(thread_parent), ProcessType::ProcessThread)
} else {
(Some(stat.ppid), ProcessType::Regular)
};
let mem_usage = stat.rss_bytes();
let mem_usage_percent = (mem_usage as f64 / total_memory as f64 * 100.0) as f32;
let virtual_mem = stat.vsize;
@ -202,7 +212,9 @@ fn read_proc(
if ticks_per_sec == 0 {
Duration::ZERO
} else {
Duration::from_secs(uptime.saturating_sub(stat.start_time / ticks_per_sec as u64))
Duration::from_secs(
system_uptime.saturating_sub(stat.start_time / ticks_per_sec as u64),
)
}
} else {
Duration::ZERO
@ -222,12 +234,15 @@ fn read_proc(
// We're only interested in the executable part, not the file path (part of command),
// so strip everything but the command name if needed.
let last_part = match first_part.rsplit_once('/') {
let command = match first_part.rsplit_once('/') {
Some((_, last)) => last,
None => first_part,
};
last_part.to_string()
// TODO: Needed as some processes have stuff like "systemd-userwork: waiting..."
// command.trim_end_matches(':').to_string()
command.to_string()
} else {
truncated_name
};
@ -263,6 +278,7 @@ fn read_proc(
gpu_mem_percent: 0.0,
#[cfg(feature = "gpu")]
gpu_util: 0,
process_type,
},
new_process_times,
))
@ -276,6 +292,7 @@ pub(crate) struct PrevProc<'a> {
pub(crate) struct ProcHarvestOptions {
pub use_current_cpu_total: bool,
pub unnormalized_cpu: bool,
pub get_process_threads: bool,
}
fn is_str_numeric(s: &str) -> bool {
@ -285,12 +302,13 @@ fn is_str_numeric(s: &str) -> bool {
/// General args to keep around for reading proc data.
#[derive(Copy, Clone)]
pub(crate) struct ReadProcArgs {
pub(crate) use_current_cpu_total: bool,
pub(crate) cpu_usage: f64,
pub(crate) cpu_fraction: f64,
pub(crate) total_memory: u64,
pub(crate) time_difference_in_secs: u64,
pub(crate) uptime: u64,
pub use_current_cpu_total: bool,
pub cpu_usage: f64,
pub cpu_fraction: f64,
pub total_memory: u64,
pub time_difference_in_secs: u64,
pub system_uptime: u64,
pub get_process_threads: bool,
}
pub(crate) fn linux_process_data(
@ -304,13 +322,15 @@ pub(crate) fn linux_process_data(
let proc_harvest_options = ProcHarvestOptions {
use_current_cpu_total: collector.use_current_cpu_total,
unnormalized_cpu: collector.unnormalized_cpu,
get_process_threads: collector.get_process_threads,
};
let pid_mapping = &mut collector.pid_mapping;
let prev_process_details = &mut collector.prev_process_details;
let user_table = &mut collector.user_table;
let ProcHarvestOptions {
use_current_cpu_total,
unnormalized_cpu,
get_process_threads: get_threads,
} = proc_harvest_options;
let PrevProc {
@ -333,9 +353,13 @@ pub(crate) fn linux_process_data(
cpu_usage /= num_processors;
}
let mut pids_to_clear: HashSet<Pid> = pid_mapping.keys().cloned().collect();
// TODO: Could maybe use a double buffer hashmap to avoid allocating this each time?
// e.g. we swap which is prev and which is new.
let mut seen_pids: HashSet<Pid> = HashSet::new();
// Note this will only return PIDs of _processes_, not threads. You can get those from /proc/<PID>/task though.
let pids = fs::read_dir("/proc")?.flatten().filter_map(|dir| {
// Need to filter out non-PID entries.
if is_str_numeric(dir.file_name().to_string_lossy().trim()) {
Some(dir.path())
} else {
@ -349,20 +373,25 @@ pub(crate) fn linux_process_data(
cpu_fraction,
total_memory,
time_difference_in_secs,
uptime: sysinfo::System::uptime(),
system_uptime: sysinfo::System::uptime(),
get_process_threads: get_threads,
};
// TODO: Maybe pre-allocate these buffers in the future w/ routine cleanup.
let mut buffer = String::new();
let mut process_threads_to_check = HashMap::new();
let process_vector: Vec<ProcessHarvest> = pids
let mut process_vector: Vec<ProcessHarvest> = pids
.filter_map(|pid_path| {
if let Ok(process) = Process::from_path(pid_path, &mut buffer) {
if let Ok((process, threads)) =
Process::from_path(pid_path, &mut buffer, args.get_process_threads)
{
let pid = process.pid;
let prev_proc_details = pid_mapping.entry(pid).or_default();
let prev_proc_details = prev_process_details.entry(pid).or_default();
#[cfg_attr(not(feature = "gpu"), expect(unused_mut))]
if let Ok((mut process_harvest, new_process_times)) =
read_proc(prev_proc_details, process, args, user_table)
read_proc(prev_proc_details, process, args, user_table, None)
{
#[cfg(feature = "gpu")]
if let Some(gpus) = &collector.gpu_pids {
@ -384,7 +413,11 @@ pub(crate) fn linux_process_data(
prev_proc_details.total_read_bytes = process_harvest.total_read;
prev_proc_details.total_write_bytes = process_harvest.total_write;
pids_to_clear.remove(&pid);
if !threads.is_empty() {
process_threads_to_check.insert(pid, threads);
}
seen_pids.insert(pid);
return Some(process_harvest);
}
}
@ -393,10 +426,37 @@ pub(crate) fn linux_process_data(
})
.collect();
pids_to_clear.iter().for_each(|pid| {
pid_mapping.remove(pid);
});
// Get thread data.
for (pid, tid_paths) in process_threads_to_check {
for tid_path in tid_paths {
if let Ok((process, _)) = Process::from_path(tid_path, &mut buffer, false) {
let tid = process.pid;
let prev_proc_details = prev_process_details.entry(tid).or_default();
if let Ok((process_harvest, new_process_times)) =
read_proc(prev_proc_details, process, args, user_table, Some(pid))
{
prev_proc_details.cpu_time = new_process_times;
prev_proc_details.total_read_bytes = process_harvest.total_read;
prev_proc_details.total_write_bytes = process_harvest.total_write;
seen_pids.insert(tid);
process_vector.push(process_harvest);
}
}
}
}
// Clean up values we don't care about anymore.
prev_process_details.retain(|pid, _| seen_pids.contains(pid));
// Occasional garbage collection.
if collector.should_run_less_routine_tasks {
prev_process_details.shrink_to_fit();
}
// TODO: This might be more efficient to just separate threads into their own list, but for now this works so it
// fits with existing code.
Ok(process_vector)
}

View File

@ -16,7 +16,7 @@ use rustix::{
path::Arg,
};
use crate::collection::processes::Pid;
use crate::collection::processes::{Pid, linux::is_str_numeric};
static PAGESIZE: OnceLock<u64> = OnceLock::new();
@ -26,8 +26,9 @@ fn next_part<'a>(iter: &mut impl Iterator<Item = &'a str>) -> Result<&'a str, io
.ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))
}
/// A wrapper around the data in `/proc/<PID>/stat`. For documentation, see
/// [here](https://man7.org/linux/man-pages/man5/proc.5.html).
/// A wrapper around the data in `/proc/<PID>/stat`. For documentation, see:
/// - <https://manpages.ubuntu.com/manpages/noble/man5/proc_pid_stat.5.html>
/// - <https://man7.org/linux/man-pages/man5/proc_pid_status.5.html>
///
/// Note this does not necessarily get all fields, only the ones we use in
/// bottom.
@ -62,7 +63,8 @@ pub(crate) struct Stat {
impl Stat {
/// Get process stats from a file; this assumes the file is located at
/// `/proc/<PID>/stat`.
/// `/proc/<PID>/stat`. For documentation, see
/// [here](https://manpages.ubuntu.com/manpages/noble/man5/proc_pid_stat.5.html) as a reference.
fn from_file(mut f: File, buffer: &mut String) -> anyhow::Result<Stat> {
// Since this is just one line, we can read it all at once. However, since it
// (technically) might have non-utf8 characters, we can't just use read_to_string.
@ -229,12 +231,15 @@ impl Process {
/// will be discarded quickly.
///
/// This takes in a buffer to avoid allocs; this function will clear the buffer.
pub(crate) fn from_path(pid_path: PathBuf, buffer: &mut String) -> anyhow::Result<Process> {
#[inline]
pub(crate) fn from_path(
pid_path: PathBuf, buffer: &mut String, get_threads: bool,
) -> anyhow::Result<(Process, Vec<PathBuf>)> {
buffer.clear();
let fd = rustix::fs::openat(
rustix::fs::CWD,
&pid_path,
pid_path.as_path(),
OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)?;
@ -245,7 +250,7 @@ impl Process {
.next_back()
.and_then(|s| s.to_string_lossy().parse::<Pid>().ok())
.or_else(|| {
rustix::fs::readlinkat(rustix::fs::CWD, &pid_path, vec![])
rustix::fs::readlinkat(rustix::fs::CWD, pid_path.as_path(), vec![])
.ok()
.and_then(|s| s.to_string_lossy().parse::<Pid>().ok())
})
@ -281,13 +286,44 @@ impl Process {
.and_then(|file| Io::from_file(file, buffer))
.ok();
Ok(Process {
pid,
uid,
stat,
io,
cmdline,
})
reset(&mut root, buffer);
let threads = if get_threads {
root.push("task");
if let Ok(task) = std::fs::read_dir(root) {
let pid_str = pid.to_string();
task.flatten()
.filter_map(|thread_dir| {
let file_name = thread_dir.file_name();
let file_name = file_name.to_string_lossy();
let file_name = file_name.trim();
if is_str_numeric(file_name) && file_name != pid_str {
Some(thread_dir.path())
} else {
None
}
})
.collect::<Vec<_>>()
} else {
Vec::new()
}
} else {
Vec::new()
};
Ok((
Process {
pid,
uid,
stat,
io,
cmdline,
},
threads,
))
}
}

View File

@ -222,6 +222,7 @@ fn create_collection_thread(
let unnormalized_cpu = app_config_fields.unnormalized_cpu;
let show_average_cpu = app_config_fields.show_average_cpu;
let update_sleep = app_config_fields.update_rate;
let get_process_threads = app_config_fields.get_process_threads;
thread::spawn(move || {
let mut data_collector = collection::DataCollector::new(filters);
@ -230,6 +231,7 @@ fn create_collection_thread(
data_collector.set_use_current_cpu_total(use_current_cpu_total);
data_collector.set_unnormalized_cpu(unnormalized_cpu);
data_collector.set_show_average_cpu(show_average_cpu);
data_collector.set_get_process_threads(get_process_threads);
data_collector.update_data();
data_collector.data = Data::default();

View File

@ -60,6 +60,20 @@ macro_rules! is_flag_enabled {
};
}
/// A new version if [`is_flag_enabled`] which instead expects the user to pass in `config_section`, which is
/// the section the flag is located, rather than defaulting to `config.flags` where `config` is passed in.
macro_rules! is_flag_enabled_new {
($flag_name:ident, $arg:expr, $config_section:expr) => {
if $arg.$flag_name {
true
} else if let Some(options) = &$config_section {
options.$flag_name.unwrap_or(false)
} else {
false
}
};
}
/// The default config file sub-path.
const DEFAULT_CONFIG_FILE_LOCATION: &str = "bottom/bottom.toml";
@ -283,6 +297,7 @@ pub(crate) fn init_app(args: BottomArgs, config: Config) -> Result<(App, BottomL
cpu_left_legend: is_flag_enabled!(cpu_left_legend, args.cpu, config),
use_current_cpu_total: is_flag_enabled!(current_usage, args.process, config),
unnormalized_cpu: is_flag_enabled!(unnormalized_cpu, args.process, config),
get_process_threads: is_flag_enabled_new!(get_threads, args.process, config.processes),
use_basic_mode,
default_time_value,
time_interval: get_time_interval(args, config, retention_ms)?,
@ -1228,7 +1243,7 @@ mod test {
.widget_states
.iter()
.zip(testing_app.states.proc_state.widget_states.iter())
.all(|(a, b)| (a.1.test_equality(b.1)))
.all(|(a, b)| a.1.test_equality(b.1))
{
panic!("failed on {arg_name}");
}

View File

@ -315,6 +315,14 @@ pub struct ProcessArgs {
)]
pub disable_advanced_kill: bool,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Also gather process thread information.",
alias = "get-threads",
)]
pub get_threads: bool,
#[arg(
short = 'g',
long,
@ -354,6 +362,14 @@ pub struct ProcessArgs {
)]
pub tree: bool,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Collapse process tree by default.",
alias = "tree-collapse"
)]
pub tree_collapse: bool,
#[arg(
short = 'n',
long,
@ -371,14 +387,6 @@ pub struct ProcessArgs {
alias = "whole-word"
)]
pub whole_word: bool,
#[arg(
long,
action = ArgAction::SetTrue,
help = "Collapse process tree by default.",
alias = "tree-collapse"
)]
pub tree_collapse: bool,
}
/// Temperature arguments/config options.

View File

@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use super::StringOrNum;
// TODO: Break this up.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "generate_schema", derive(schemars::JsonSchema))]
#[cfg_attr(test, serde(deny_unknown_fields), derive(PartialEq, Eq))]

View File

@ -9,7 +9,10 @@ use crate::widgets::ProcColumn;
pub(crate) struct ProcessesConfig {
/// A list of process widget columns.
#[serde(default)]
pub(crate) columns: Vec<ProcColumn>, // TODO: make this more composable(?) in the future, we might need to rethink how it's done for custom widgets
pub columns: Vec<ProcColumn>, // TODO: make this more composable(?) in the future, we might need to rethink how it's done for custom widgets
/// Whether to get process child threads.
pub get_threads: Option<bool>,
}
#[cfg(test)]

View File

@ -124,6 +124,8 @@ pub struct Styles {
pub(crate) low_battery: Style,
pub(crate) invalid_query_style: Style,
pub(crate) disabled_text_style: Style,
#[cfg(target_os = "linux")]
pub(crate) thread_text_style: Style,
pub(crate) border_type: BorderType,
}

View File

@ -67,6 +67,8 @@ impl Styles {
invalid_query_style: color!(Color::Red),
disabled_text_style: color!(Color::DarkGray),
border_type: BorderType::Plain,
#[cfg(target_os = "linux")]
thread_text_style: color!(Color::Green),
}
}

View File

@ -67,6 +67,8 @@ impl Styles {
invalid_query_style: color!(Color::Red),
disabled_text_style: hex!("#665c54"),
border_type: BorderType::Plain,
#[cfg(target_os = "linux")]
thread_text_style: hex!("#458588"),
}
}
@ -130,6 +132,8 @@ impl Styles {
invalid_query_style: color!(Color::Red),
disabled_text_style: hex!("#d5c4a1"),
border_type: BorderType::Plain,
#[cfg(target_os = "linux")]
thread_text_style: hex!("#458588"),
}
}
}

View File

@ -55,6 +55,8 @@ impl Styles {
invalid_query_style: color!(Color::Red),
disabled_text_style: hex!("#4c566a"),
border_type: BorderType::Plain,
#[cfg(target_os = "linux")]
thread_text_style: hex!("#a3be8c"),
}
}
@ -106,6 +108,8 @@ impl Styles {
invalid_query_style: color!(Color::Red),
disabled_text_style: hex!("#d8dee9"),
border_type: BorderType::Plain,
#[cfg(target_os = "linux")]
thread_text_style: hex!("#a3be8c"),
}
}
}

View File

@ -45,7 +45,7 @@ impl CpuWidgetTableData {
}
impl DataToCell<CpuWidgetColumn> for CpuWidgetTableData {
fn to_cell(
fn to_cell_text(
&self, column: &CpuWidgetColumn, calculated_width: NonZeroU16,
) -> Option<Cow<'static, str>> {
const CPU_TRUNCATE_BREAKPOINT: u16 = 5;

View File

@ -159,7 +159,7 @@ impl ColumnHeader for DiskColumn {
impl DataToCell<DiskColumn> for DiskWidgetData {
// FIXME: (points_rework_v1) Can we change the return type to 'a instead of 'static?
fn to_cell(
fn to_cell_text(
&self, column: &DiskColumn, _calculated_width: NonZeroU16,
) -> Option<Cow<'static, str>> {
fn percent_string(value: Option<f64>) -> Cow<'static, str> {

View File

@ -1176,6 +1176,8 @@ mod test {
gpu_mem_usage: MemUsage::Percent(1.1),
#[cfg(feature = "gpu")]
gpu_usage: 0,
#[cfg(target_os = "linux")]
process_type: crate::collection::processes::ProcessType::Regular,
};
let b = ProcWidgetData {
@ -1210,23 +1212,23 @@ mod test {
data.sort_by_key(|p| p.pid);
sort_skip_pid_asc(&ProcColumn::CpuPercent, &mut data, SortOrder::Descending);
assert_eq!(
[&c, &b, &a, &d].iter().map(|d| (d.pid)).collect::<Vec<_>>(),
data.iter().map(|d| (d.pid)).collect::<Vec<_>>(),
[&c, &b, &a, &d].iter().map(|d| d.pid).collect::<Vec<_>>(),
data.iter().map(|d| d.pid).collect::<Vec<_>>(),
);
// Note that the PID ordering for ties is still ascending.
data.sort_by_key(|p| p.pid);
sort_skip_pid_asc(&ProcColumn::CpuPercent, &mut data, SortOrder::Ascending);
assert_eq!(
[&a, &d, &b, &c].iter().map(|d| (d.pid)).collect::<Vec<_>>(),
data.iter().map(|d| (d.pid)).collect::<Vec<_>>(),
[&a, &d, &b, &c].iter().map(|d| d.pid).collect::<Vec<_>>(),
data.iter().map(|d| d.pid).collect::<Vec<_>>(),
);
data.sort_by_key(|p| p.pid);
sort_skip_pid_asc(&ProcColumn::MemPercent, &mut data, SortOrder::Descending);
assert_eq!(
[&b, &a, &c, &d].iter().map(|d| (d.pid)).collect::<Vec<_>>(),
data.iter().map(|d| (d.pid)).collect::<Vec<_>>(),
[&b, &a, &c, &d].iter().map(|d| d.pid).collect::<Vec<_>>(),
data.iter().map(|d| d.pid).collect::<Vec<_>>(),
);
// Note that the PID ordering for ties is still ascending.

View File

@ -10,6 +10,7 @@ use concat_string::concat_string;
use tui::widgets::Row;
use super::process_columns::ProcColumn;
use crate::{
canvas::{
Painter,
@ -214,6 +215,9 @@ pub struct ProcWidgetData {
pub gpu_mem_usage: MemUsage,
#[cfg(feature = "gpu")]
pub gpu_usage: u32,
/// The process "type". Used to color things.
#[cfg(target_os = "linux")]
pub process_type: crate::collection::processes::ProcessType,
}
impl ProcWidgetData {
@ -258,6 +262,8 @@ impl ProcWidgetData {
},
#[cfg(feature = "gpu")]
gpu_usage: process.gpu_util,
#[cfg(target_os = "linux")]
process_type: process.process_type,
}
}
@ -324,7 +330,7 @@ impl ProcWidgetData {
}
impl DataToCell<ProcColumn> for ProcWidgetData {
fn to_cell(
fn to_cell_text(
&self, column: &ProcColumn, calculated_width: NonZeroU16,
) -> Option<Cow<'static, str>> {
let calculated_width = calculated_width.get();
@ -361,6 +367,17 @@ impl DataToCell<ProcColumn> for ProcWidgetData {
})
}
#[cfg(target_os = "linux")]
#[inline(always)]
fn style_cell(&self, column: &ProcColumn, painter: &Painter) -> Option<tui::style::Style> {
match column {
ProcColumn::Name | ProcColumn::Command if self.process_type.is_thread() => {
Some(painter.styles.thread_text_style)
}
_ => None,
}
}
#[inline(always)]
fn style_row<'a>(&self, row: Row<'a>, painter: &Painter) -> Row<'a> {
if self.disabled {

View File

@ -11,7 +11,7 @@ impl ColumnHeader for SortTableColumn {
}
impl DataToCell<SortTableColumn> for &'static str {
fn to_cell(
fn to_cell_text(
&self, _column: &SortTableColumn, _calculated_width: NonZeroU16,
) -> Option<Cow<'static, str>> {
Some(Cow::Borrowed(self))
@ -26,7 +26,7 @@ impl DataToCell<SortTableColumn> for &'static str {
}
impl DataToCell<SortTableColumn> for Cow<'static, str> {
fn to_cell(
fn to_cell_text(
&self, _column: &SortTableColumn, _calculated_width: NonZeroU16,
) -> Option<Cow<'static, str>> {
Some(self.clone())

View File

@ -40,7 +40,7 @@ impl TempWidgetData {
}
impl DataToCell<TempWidgetColumn> for TempWidgetData {
fn to_cell(
fn to_cell_text(
&self, column: &TempWidgetColumn, _calculated_width: NonZeroU16,
) -> Option<Cow<'static, str>> {
Some(match column {