diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc7160b..b62a4d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index a1c73b3e..e9dad81a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,7 +145,7 @@ checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" [[package]] name = "bottom" -version = "0.11.1" +version = "0.12.0-nightly" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 9960dafb..efa923ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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." diff --git a/schema/nightly/bottom.json b/schema/nightly/bottom.json index 1b77d18f..287e5394 100644 --- a/schema/nightly/bottom.json +++ b/schema/nightly/bottom.json @@ -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" + ] } } }, diff --git a/src/app.rs b/src/app.rs index 8d9417ac..2ae88928 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, diff --git a/src/canvas/components/data_table.rs b/src/canvas/components/data_table.rs index 734e46e0..ab8d6b3d 100644 --- a/src/canvas/components/data_table.rs +++ b/src/canvas/components/data_table.rs @@ -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> { None diff --git a/src/canvas/components/data_table/data_type.rs b/src/canvas/components/data_table/data_type.rs index c027d63e..a03fcc6a 100644 --- a/src/canvas/components/data_table/data_type.rs +++ b/src/canvas/components/data_table/data_type.rs @@ -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>; + /// the cell that will be displayed in the [`super::DataTable`]. + fn to_cell_text(&self, column: &H, calculated_width: NonZeroU16) -> Option>; + + /// 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 { + 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 } diff --git a/src/canvas/components/data_table/draw.rs b/src/canvas/components/data_table/draw.rs index d97c3b80..7ff14e24 100644 --- a/src/canvas/components/data_table/draw.rs +++ b/src/canvas/components/data_table/draw.rs @@ -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) + } + }) }), ); diff --git a/src/canvas/components/data_table/sortable.rs b/src/canvas/components/data_table/sortable.rs index c3ef4f49..1509553c 100644 --- a/src/canvas/components/data_table/sortable.rs +++ b/src/canvas/components/data_table/sortable.rs @@ -360,7 +360,7 @@ mod test { } impl DataToCell for TestType { - fn to_cell( + fn to_cell_text( &self, _column: &ColumnType, _calculated_width: NonZeroU16, ) -> Option> { None diff --git a/src/canvas/widgets/process_table.rs b/src/canvas/widgets/process_table.rs index 22614508..961df4a7 100644 --- a/src/canvas/widgets/process_table.rs +++ b/src/canvas/widgets/process_table.rs @@ -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, ) { diff --git a/src/collection.rs b/src/collection.rs index 113b817c..93a16fa1 100644 --- a/src/collection.rs +++ b/src/collection.rs @@ -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, + prev_process_details: HashMap, #[cfg(target_os = "linux")] prev_idle: f64, #[cfg(target_os = "linux")] @@ -187,25 +186,26 @@ pub struct DataCollector { gpus_total_mem: Option, } -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::>()); diff --git a/src/collection/processes.rs b/src/collection/processes.rs index c409e236..735821e8 100644 --- a/src/collection/processes.rs +++ b/src/collection/processes.rs @@ -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 ? + 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, diff --git a/src/collection/processes/linux/mod.rs b/src/collection/processes/linux/mod.rs index d85079c2..3619147f 100644 --- a/src/collection/processes/linux/mod.rs +++ b/src/collection/processes/linux/mod.rs @@ -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//stat`` process name. +/// Maximum character length of a `/proc//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, ) -> 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_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 = HashSet::new(); + // Note this will only return PIDs of _processes_, not threads. You can get those from /proc//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 = pids + let mut process_vector: Vec = 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) } diff --git a/src/collection/processes/linux/process.rs b/src/collection/processes/linux/process.rs index 98d8fd0b..d17eb646 100644 --- a/src/collection/processes/linux/process.rs +++ b/src/collection/processes/linux/process.rs @@ -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 = OnceLock::new(); @@ -26,8 +26,9 @@ fn next_part<'a>(iter: &mut impl Iterator) -> Result<&'a str, io .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData)) } -/// A wrapper around the data in `/proc//stat`. For documentation, see -/// [here](https://man7.org/linux/man-pages/man5/proc.5.html). +/// A wrapper around the data in `/proc//stat`. For documentation, see: +/// - +/// - /// /// 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//stat`. + /// `/proc//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 { // 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 { + #[inline] + pub(crate) fn from_path( + pid_path: PathBuf, buffer: &mut String, get_threads: bool, + ) -> anyhow::Result<(Process, Vec)> { 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::().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::().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::>() + } else { + Vec::new() + } + } else { + Vec::new() + }; + + Ok(( + Process { + pid, + uid, + stat, + io, + cmdline, + }, + threads, + )) } } diff --git a/src/lib.rs b/src/lib.rs index 9b649c61..deeec578 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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(); diff --git a/src/options.rs b/src/options.rs index 23c288a6..38a6eecd 100644 --- a/src/options.rs +++ b/src/options.rs @@ -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}"); } diff --git a/src/options/args.rs b/src/options/args.rs index feb4420d..8d9af597 100644 --- a/src/options/args.rs +++ b/src/options/args.rs @@ -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. diff --git a/src/options/config/flags.rs b/src/options/config/flags.rs index 621d5e15..5eb0ae7b 100644 --- a/src/options/config/flags.rs +++ b/src/options/config/flags.rs @@ -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))] diff --git a/src/options/config/process.rs b/src/options/config/process.rs index 7490e380..8629e24c 100644 --- a/src/options/config/process.rs +++ b/src/options/config/process.rs @@ -9,7 +9,10 @@ use crate::widgets::ProcColumn; pub(crate) struct ProcessesConfig { /// A list of process 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 + pub columns: Vec, // 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, } #[cfg(test)] diff --git a/src/options/config/style.rs b/src/options/config/style.rs index 96d8cdf4..1ee1500b 100644 --- a/src/options/config/style.rs +++ b/src/options/config/style.rs @@ -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, } diff --git a/src/options/config/style/themes/default.rs b/src/options/config/style/themes/default.rs index b5f88c29..1704af60 100644 --- a/src/options/config/style/themes/default.rs +++ b/src/options/config/style/themes/default.rs @@ -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), } } diff --git a/src/options/config/style/themes/gruvbox.rs b/src/options/config/style/themes/gruvbox.rs index 0cbf1f88..f4e4e143 100644 --- a/src/options/config/style/themes/gruvbox.rs +++ b/src/options/config/style/themes/gruvbox.rs @@ -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"), } } } diff --git a/src/options/config/style/themes/nord.rs b/src/options/config/style/themes/nord.rs index 28fa7be1..4d79690a 100644 --- a/src/options/config/style/themes/nord.rs +++ b/src/options/config/style/themes/nord.rs @@ -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"), } } } diff --git a/src/widgets/cpu_graph.rs b/src/widgets/cpu_graph.rs index 9d27aff9..75dfa04d 100644 --- a/src/widgets/cpu_graph.rs +++ b/src/widgets/cpu_graph.rs @@ -45,7 +45,7 @@ impl CpuWidgetTableData { } impl DataToCell for CpuWidgetTableData { - fn to_cell( + fn to_cell_text( &self, column: &CpuWidgetColumn, calculated_width: NonZeroU16, ) -> Option> { const CPU_TRUNCATE_BREAKPOINT: u16 = 5; diff --git a/src/widgets/disk_table.rs b/src/widgets/disk_table.rs index 9d71c9cc..cd9b55e7 100644 --- a/src/widgets/disk_table.rs +++ b/src/widgets/disk_table.rs @@ -159,7 +159,7 @@ impl ColumnHeader for DiskColumn { impl DataToCell 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> { fn percent_string(value: Option) -> Cow<'static, str> { diff --git a/src/widgets/process_table.rs b/src/widgets/process_table.rs index b7f8801b..b89f28be 100644 --- a/src/widgets/process_table.rs +++ b/src/widgets/process_table.rs @@ -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::>(), - data.iter().map(|d| (d.pid)).collect::>(), + [&c, &b, &a, &d].iter().map(|d| d.pid).collect::>(), + data.iter().map(|d| d.pid).collect::>(), ); // 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::>(), - data.iter().map(|d| (d.pid)).collect::>(), + [&a, &d, &b, &c].iter().map(|d| d.pid).collect::>(), + data.iter().map(|d| d.pid).collect::>(), ); 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::>(), - data.iter().map(|d| (d.pid)).collect::>(), + [&b, &a, &c, &d].iter().map(|d| d.pid).collect::>(), + data.iter().map(|d| d.pid).collect::>(), ); // Note that the PID ordering for ties is still ascending. diff --git a/src/widgets/process_table/process_data.rs b/src/widgets/process_table/process_data.rs index 2e4b3f12..1a468488 100644 --- a/src/widgets/process_table/process_data.rs +++ b/src/widgets/process_table/process_data.rs @@ -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 for ProcWidgetData { - fn to_cell( + fn to_cell_text( &self, column: &ProcColumn, calculated_width: NonZeroU16, ) -> Option> { let calculated_width = calculated_width.get(); @@ -361,6 +367,17 @@ impl DataToCell for ProcWidgetData { }) } + #[cfg(target_os = "linux")] + #[inline(always)] + fn style_cell(&self, column: &ProcColumn, painter: &Painter) -> Option { + 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 { diff --git a/src/widgets/process_table/sort_table.rs b/src/widgets/process_table/sort_table.rs index 02409785..12314fc6 100644 --- a/src/widgets/process_table/sort_table.rs +++ b/src/widgets/process_table/sort_table.rs @@ -11,7 +11,7 @@ impl ColumnHeader for SortTableColumn { } impl DataToCell for &'static str { - fn to_cell( + fn to_cell_text( &self, _column: &SortTableColumn, _calculated_width: NonZeroU16, ) -> Option> { Some(Cow::Borrowed(self)) @@ -26,7 +26,7 @@ impl DataToCell for &'static str { } impl DataToCell for Cow<'static, str> { - fn to_cell( + fn to_cell_text( &self, _column: &SortTableColumn, _calculated_width: NonZeroU16, ) -> Option> { Some(self.clone()) diff --git a/src/widgets/temperature_table.rs b/src/widgets/temperature_table.rs index 65ee6b91..c9496916 100644 --- a/src/widgets/temperature_table.rs +++ b/src/widgets/temperature_table.rs @@ -40,7 +40,7 @@ impl TempWidgetData { } impl DataToCell for TempWidgetData { - fn to_cell( + fn to_cell_text( &self, column: &TempWidgetColumn, _calculated_width: NonZeroU16, ) -> Option> { Some(match column {