From a9502c459f178f25362f5140cb1a360ee33d9573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tuna=20K=C4=B1l=C4=B1=C3=A7?= Date: Mon, 14 Sep 2026 22:17:43 +0300 Subject: [PATCH 1/9] feat(ui): adapt compact layouts --- assets/config.template.toml | 2 ++ src/app.rs | 40 ++++++++++++++++++++++- src/config/model.rs | 4 +++ src/ui/chrome.rs | 65 +++++++++++++++++++++++++++++++++++-- src/ui/header.rs | 15 ++++++++- src/ui/mod.rs | 51 +++++++++++++++++++---------- 6 files changed, 155 insertions(+), 22 deletions(-) diff --git a/assets/config.template.toml b/assets/config.template.toml index a052e79..e8caf74 100644 --- a/assets/config.template.toml +++ b/assets/config.template.toml @@ -229,3 +229,5 @@ scroll_up = ["up", "k"] sort_next = ["s"] toggle_per_core = ["c"] freeze = ["space"] +info_details = ["i"] +errors = ["e"] diff --git a/src/app.rs b/src/app.rs index fe021f8..2c97da5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -158,6 +158,8 @@ pub enum Action { SortNext, TogglePerCore, ToggleFreeze, + ToggleInfoDetails, + ToggleErrors, } /// A transient message shown in the footer. @@ -197,8 +199,11 @@ pub struct App { pub per_core: bool, pub frozen: bool, pub show_help: bool, + pub show_info_details: bool, + pub show_errors: bool, pub scroll: usize, pub status: Option, + pub errors: VecDeque, pub should_quit: bool, pub started: Instant, sampler: Sampler, @@ -240,8 +245,11 @@ impl App { logo, frozen: false, show_help: false, + show_info_details: false, + show_errors: false, scroll: 0, status: None, + errors: VecDeque::with_capacity(32), should_quit: false, started: Instant::now(), sampler: Sampler::new(), @@ -278,7 +286,11 @@ impl App { ); self.history.record(&self.dynamic, &self.config); - if self.status.as_ref().is_some_and(Status::expired) { + if self + .status + .as_ref() + .is_some_and(|status| !status.is_error && status.expired()) + { self.status = None; } if self.config.general.watch_config { @@ -305,6 +317,12 @@ impl App { if self.show_help && (hit(&k.help) || hit(&k.quit)) { return Action::ToggleHelp; } + if self.show_info_details && (hit(&k.info_details) || hit(&k.quit)) { + return Action::ToggleInfoDetails; + } + if self.show_errors && (hit(&k.errors) || hit(&k.quit)) { + return Action::ToggleErrors; + } if hit(&k.quit) { Action::Quit @@ -326,6 +344,10 @@ impl App { Action::TogglePerCore } else if hit(&k.freeze) { Action::ToggleFreeze + } else if hit(&k.info_details) { + Action::ToggleInfoDetails + } else if hit(&k.errors) { + Action::ToggleErrors } else { Action::None } @@ -368,6 +390,15 @@ impl App { let word = if self.frozen { "frozen" } else { "running" }; self.set_status(word, false); } + Action::ToggleInfoDetails => { + self.show_info_details = !self.show_info_details; + } + Action::ToggleErrors => { + self.show_errors = !self.show_errors; + if !self.show_errors && self.status.as_ref().is_some_and(|s| s.is_error) { + self.status = None; + } + } } } @@ -385,6 +416,13 @@ impl App { /// Shows a message in the footer. pub fn set_status(&mut self, text: impl Into, is_error: bool) { + let text = text.into(); + if is_error { + if self.errors.len() == 32 { + self.errors.pop_front(); + } + self.errors.push_back(text.clone()); + } self.status = Some(Status::new(text, is_error)); } diff --git a/src/config/model.rs b/src/config/model.rs index ee7c725..3550956 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -468,6 +468,8 @@ pub struct Keys { pub sort_next: Vec, pub toggle_per_core: Vec, pub freeze: Vec, + pub info_details: Vec, + pub errors: Vec, } impl Default for Keys { @@ -484,6 +486,8 @@ impl Default for Keys { sort_next: b(&["s"]), toggle_per_core: b(&["c"]), freeze: b(&["space"]), + info_details: b(&["i"]), + errors: b(&["e"]), } } } diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index fb99569..079230f 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -3,7 +3,7 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Clear, Paragraph}; +use ratatui::widgets::{Clear, Paragraph, Wrap}; use ratatui::Frame; use crate::app::App; @@ -30,11 +30,17 @@ pub fn tab_bar(frame: &mut Frame, area: Rect, app: &App) { } let left = Line::from(spans); - let right = Line::from(vec![ + let mut right = Line::from(vec![ Span::styled(if app.frozen { "⏸ frozen " } else { "" }, theme.warn.fg()), Span::styled(format!("ratfetch {} ", crate::VERSION), theme.muted.fg()), ]); + if left.width().saturating_add(right.width()) > area.width as usize { + right = Line::from(Span::styled( + if app.frozen { "⏸ frozen " } else { "" }, + theme.warn.fg(), + )); + } let chunks = Layout::horizontal([Constraint::Min(0), Constraint::Length(right.width() as u16)]) .split(area); @@ -78,6 +84,10 @@ pub fn footer(frame: &mut Frame, area: Rect, app: &App) { crate::util::truncate(&status.text, area.width.saturating_sub(3) as usize), color.fg(), ), + Span::styled( + if status.is_error { " e details" } else { "" }, + theme.footer.fg(), + ), ]); frame.render_widget(Paragraph::new(line).style(theme.background.bg()), area); return; @@ -95,6 +105,10 @@ pub fn footer(frame: &mut Frame, area: Rect, app: &App) { app, )); spans.extend(hint(&k.reload, "reload", app)); + if app.tab == crate::config::enums::Tab::Overview { + spans.extend(hint(&k.info_details, "info", app)); + } + spans.extend(hint(&k.scroll_down, "scroll", app)); frame.render_widget( Paragraph::new(Line::from(spans)).style(theme.background.bg()), @@ -172,7 +186,7 @@ pub fn help(frame: &mut Frame, area: Rect, app: &App) { let theme = &app.theme; let k = &app.config.keys; - let entries: [(&str, &[KeyBinding]); 10] = [ + let entries: [(&str, &[KeyBinding]); 12] = [ ("quit", &k.quit), ("this help", &k.help), ("reload config", &k.reload), @@ -183,6 +197,8 @@ pub fn help(frame: &mut Frame, area: Rect, app: &App) { ("cycle sort column", &k.sort_next), ("toggle per-core meters", &k.toggle_per_core), ("freeze / resume", &k.freeze), + ("full system information", &k.info_details), + ("error details", &k.errors), ]; let key_column = entries @@ -243,6 +259,49 @@ pub fn help(frame: &mut Frame, area: Rect, app: &App) { ); } +fn text_popup(frame: &mut Frame, area: Rect, app: &App, title: &str, lines: Vec>) { + let width = area.width.saturating_sub(4).min(100).max(1); + let height = (lines.len() as u16 + 2) + .min(area.height.saturating_sub(2)) + .max(1); + let popup = centred(area, width, height); + frame.render_widget(Clear, popup); + let block = super::panel(&app.theme, title).border_style(app.theme.border_focus.fg()); + let inner = block.inner(popup); + frame.render_widget(block, popup); + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .style(super::surface(&app.theme)), + inner, + ); +} + +/// All system information without the overview's one-line truncation. +pub fn info_details(frame: &mut Frame, area: Rect, app: &App) { + text_popup( + frame, + area, + app, + " Information — i/esc close ", + super::header::detail_lines(app), + ); +} + +/// Recent errors, retained after the footer notification is dismissed. +pub fn errors(frame: &mut Frame, area: Rect, app: &App) { + let lines = if app.errors.is_empty() { + vec![Line::from("No errors recorded.")] + } else { + app.errors + .iter() + .rev() + .map(|error| Line::from(format!("! {error}"))) + .collect() + }; + text_popup(frame, area, app, " Errors — e/esc close ", lines); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/header.rs b/src/ui/header.rs index 47c846a..0e5246c 100644 --- a/src/ui/header.rs +++ b/src/ui/header.rs @@ -214,6 +214,11 @@ pub fn info_lines(app: &App, width: u16) -> Vec> { out } +/// Full-width information for the details overlay. +pub fn detail_lines(app: &App) -> Vec> { + info_lines(app, u16::MAX) +} + /// A one-line summary of live load, shown under the info table when there is /// room for it. pub fn live_line(app: &App) -> Line<'static> { @@ -334,6 +339,12 @@ pub fn draw(frame: &mut Frame, area: Rect, app: &App) { if is_stacked(app, inner.width) { let logo = app.logo.as_ref().expect("logo_visible checked it"); + let logo_height = clamp_logo_height(logo, &app.config); + // A logo is decoration; on a short split pane the facts take priority. + if inner.height < logo_height.saturating_add(6) { + info(frame, inner, app); + return; + } // The logo never takes so much height that the table has no room. let height = clamp_logo_height(logo, &app.config).min(inner.height.saturating_sub(inner.height / 3)); @@ -383,7 +394,9 @@ fn logo_widget(frame: &mut Frame, area: Rect, app: &App) { }; frame.render_widget( - Paragraph::new(lines).style(super::surface(&app.theme)), + Paragraph::new(lines) + .scroll((app.scroll.min(u16::MAX as usize) as u16, 0)) + .style(super::surface(&app.theme)), area, ); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index fa18505..f225b62 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -48,6 +48,10 @@ pub fn draw(frame: &mut Frame, app: &App) { if app.show_help { chrome::help(frame, area, app); + } else if app.show_info_details { + chrome::info_details(frame, area, app); + } else if app.show_errors { + chrome::errors(frame, area, app); } } @@ -62,7 +66,18 @@ fn body(frame: &mut Frame, area: Rect, app: &App) { /// The logo and info header, with the configured panels stacked underneath. fn overview(frame: &mut Frame, area: Rect, app: &App) { - let panels = &app.config.layout.panels; + // On a short, narrow terminal graphs collapse into a few meaningless cells. + // Keep the information and meters reachable and let the richer graph view + // return as soon as the terminal grows. + let compact = area.width < app.config.layout.narrow_width && area.height < 30; + let panels: Vec<_> = app + .config + .layout + .panels + .iter() + .copied() + .filter(|panel| !(compact && matches!(panel, PanelKind::Graphs | PanelKind::Colors))) + .collect(); let header_height = match app.config.layout.header_height { 0 => header::preferred_height(app, area), @@ -74,7 +89,8 @@ fn overview(frame: &mut Frame, area: Rect, app: &App) { } else { // Otherwise it gives up whatever the panels need at minimum, so a tall // logo on a short terminal cannot squeeze them out of the frame. - header_height.min(area.height.saturating_sub(panels_min_height(app)).max(1)) + let panel_rows = panels.iter().map(|p| panel_min_height(app, *p)).sum(); + header_height.min(area.height.saturating_sub(panel_rows).max(1)) }; let chunks = @@ -129,28 +145,29 @@ fn overview(frame: &mut Frame, area: Rect, app: &App) { /// The header is capped to whatever is left over, and `--once` uses it to size /// its viewport so nothing is cut off. pub fn panels_min_height(app: &App) -> u16 { - let heights = &app.config.layout.panel_heights; - // Anything that scrolls needs a border pair plus a header row and one row - // of content to be worth drawing at all. - const SCROLLING_MIN: u16 = 4; - app.config .layout .panels .iter() - .map(|panel| match panel { - PanelKind::Meters if heights.meters > 0 => heights.meters, - PanelKind::Meters => meters::natural_height(app), - PanelKind::Graphs if heights.graphs > 0 => heights.graphs, - PanelKind::Processes if heights.processes > 0 => heights.processes, - PanelKind::Disks if heights.disks > 0 => heights.disks, - PanelKind::Network if heights.network > 0 => heights.network, - PanelKind::Colors => heights.colors.max(1), - _ => SCROLLING_MIN, - }) + .map(|panel| panel_min_height(app, *panel)) .fold(0u16, u16::saturating_add) } +fn panel_min_height(app: &App, panel: PanelKind) -> u16 { + let heights = &app.config.layout.panel_heights; + const SCROLLING_MIN: u16 = 4; + match panel { + PanelKind::Meters if heights.meters > 0 => heights.meters, + PanelKind::Meters => meters::natural_height(app), + PanelKind::Graphs if heights.graphs > 0 => heights.graphs, + PanelKind::Processes if heights.processes > 0 => heights.processes, + PanelKind::Disks if heights.disks > 0 => heights.disks, + PanelKind::Network if heights.network > 0 => heights.network, + PanelKind::Colors => heights.colors.max(1), + _ => SCROLLING_MIN, + } +} + /// Insets a rect on all sides, without underflowing. pub fn shrink(area: Rect, by: u16) -> Rect { let horizontal = by.saturating_mul(2); From 7fda0053a894ae8ad3863c65d46b65380f385236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tuna=20K=C4=B1l=C4=B1=C3=A7?= Date: Tue, 15 Sep 2026 00:42:18 +0300 Subject: [PATCH 2/9] feat(processes): add interactive inspection --- assets/config.template.toml | 5 ++ src/app.rs | 170 +++++++++++++++++++++++++++++++++++- src/cli.rs | 3 + src/config/model.rs | 10 +++ src/sys/procs.rs | 84 ++++++++++++++++++ src/ui/chrome.rs | 52 ++++++++++- src/ui/mod.rs | 4 + src/ui/tables.rs | 69 ++++++++++++--- 8 files changed, 380 insertions(+), 17 deletions(-) diff --git a/assets/config.template.toml b/assets/config.template.toml index e8caf74..add729e 100644 --- a/assets/config.template.toml +++ b/assets/config.template.toml @@ -231,3 +231,8 @@ toggle_per_core = ["c"] freeze = ["space"] info_details = ["i"] errors = ["e"] +search_processes = ["/"] +clear_process_filter = ["ctrl+u"] +process_details = ["enter"] +process_tree = ["t"] +signal_process = ["x"] diff --git a/src/app.rs b/src/app.rs index 2c97da5..c902178 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,9 @@ use std::collections::VecDeque; use std::path::PathBuf; use std::time::{Duration, Instant, SystemTime}; -use crossterm::event::{KeyEvent, KeyEventKind, MouseEvent, MouseEventKind}; +use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; use crate::config::enums::{ProcSort, Tab}; use crate::config::theme::{self, Theme}; @@ -160,6 +162,14 @@ pub enum Action { ToggleFreeze, ToggleInfoDetails, ToggleErrors, + SearchProcesses, + ClearProcessFilter, + ToggleProcessDetails, + ToggleProcessTree, + SignalProcess, + ConfirmSignal, + CancelSignal, + SelectTab(Tab), } /// A transient message shown in the footer. @@ -201,6 +211,11 @@ pub struct App { pub show_help: bool, pub show_info_details: bool, pub show_errors: bool, + pub process_searching: bool, + pub process_query: String, + pub process_tree: bool, + pub show_process_details: bool, + pub show_signal_confirm: bool, pub scroll: usize, pub status: Option, pub errors: VecDeque, @@ -247,6 +262,11 @@ impl App { show_help: false, show_info_details: false, show_errors: false, + process_searching: false, + process_query: String::new(), + process_tree: false, + show_process_details: false, + show_signal_confirm: false, scroll: 0, status: None, errors: VecDeque::with_capacity(32), @@ -285,6 +305,10 @@ impl App { self.config.processes.ascending, ); self.history.record(&self.dynamic, &self.config); + let rows = + crate::sys::procs::view(&self.dynamic.procs, &self.process_query, self.process_tree) + .len(); + self.scroll = self.scroll.min(rows.saturating_sub(1)); if self .status @@ -313,6 +337,18 @@ impl App { let k = &self.config.keys; let hit = |list: &[crate::config::KeyBinding]| list.iter().any(|b| b.matches(key)); + if self.show_signal_confirm { + return match key.code { + KeyCode::Char('y') => Action::ConfirmSignal, + KeyCode::Char('n') => Action::CancelSignal, + _ if hit(&k.quit) => Action::CancelSignal, + _ => Action::None, + }; + } + if self.show_process_details && (hit(&k.process_details) || hit(&k.quit)) { + return Action::ToggleProcessDetails; + } + // Help is modal: while it is open, any bound key closes it. if self.show_help && (hit(&k.help) || hit(&k.quit)) { return Action::ToggleHelp; @@ -348,6 +384,16 @@ impl App { Action::ToggleInfoDetails } else if hit(&k.errors) { Action::ToggleErrors + } else if self.tab == Tab::Processes && hit(&k.search_processes) { + Action::SearchProcesses + } else if self.tab == Tab::Processes && hit(&k.clear_process_filter) { + Action::ClearProcessFilter + } else if self.tab == Tab::Processes && hit(&k.process_details) { + Action::ToggleProcessDetails + } else if self.tab == Tab::Processes && hit(&k.process_tree) { + Action::ToggleProcessTree + } else if self.tab == Tab::Processes && hit(&k.signal_process) { + Action::SignalProcess } else { Action::None } @@ -358,6 +404,24 @@ impl App { match ev.kind { MouseEventKind::ScrollDown => Action::ScrollDown, MouseEventKind::ScrollUp => Action::ScrollUp, + MouseEventKind::Down(MouseButton::Left) + if self.config.general.tab_bar + && ev.row == self.config.layout.margin + && self.config.general.tabs.len() > 1 => + { + let mut column = self.config.layout.margin.saturating_add(1); + for (index, tab) in self.config.general.tabs.iter().enumerate() { + if index > 0 { + column = column.saturating_add(3); + } + let end = column.saturating_add(tab.to_string().chars().count() as u16 + 2); + if ev.column >= column && ev.column < end { + return Action::SelectTab(*tab); + } + column = end; + } + Action::None + } _ => Action::None, } } @@ -371,7 +435,21 @@ impl App { Action::Reload => self.reload(), Action::NextTab => self.switch_tab(1), Action::PrevTab => self.switch_tab(-1), - Action::ScrollDown => self.scroll = self.scroll.saturating_add(1), + Action::ScrollDown => { + let max = match self.tab { + Tab::Processes => crate::sys::procs::view( + &self.dynamic.procs, + &self.process_query, + self.process_tree, + ) + .len() + .saturating_sub(1), + Tab::Disks => self.dynamic.disks.len().saturating_sub(1), + Tab::Network => self.dynamic.nets.len().saturating_sub(1), + Tab::Overview => 4095, + }; + self.scroll = self.scroll.saturating_add(1).min(max); + } Action::ScrollUp => self.scroll = self.scroll.saturating_sub(1), Action::SortNext => { self.sort = self.sort.next(); @@ -399,7 +477,95 @@ impl App { self.status = None; } } + Action::SearchProcesses => { + self.process_searching = true; + self.scroll = 0; + } + Action::ClearProcessFilter => { + self.process_query.clear(); + self.scroll = 0; + } + Action::ToggleProcessDetails => { + if self.selected_process().is_some() { + self.show_process_details = !self.show_process_details; + } + } + Action::ToggleProcessTree => { + self.process_tree = !self.process_tree; + self.scroll = 0; + self.set_status( + if self.process_tree { + "process tree" + } else { + "sorted process list" + }, + false, + ); + } + Action::SignalProcess => { + self.show_signal_confirm = self.selected_process().is_some(); + } + Action::ConfirmSignal => { + let selected = self + .selected_process() + .map(|process| (process.pid, process.name.clone())); + self.show_signal_confirm = false; + if let Some((pid, name)) = selected { + // SAFETY: kill does not dereference pointers; it reports failure. + let result = unsafe { libc::kill(pid, libc::SIGTERM) }; + if result == 0 { + self.set_status(format!("sent SIGTERM to {name} ({pid})"), false); + } else { + self.set_status( + format!( + "cannot signal {name} ({pid}): {}", + std::io::Error::last_os_error() + ), + true, + ); + } + } + } + Action::CancelSignal => self.show_signal_confirm = false, + Action::SelectTab(tab) => { + self.tab = tab; + self.scroll = 0; + } + } + } + + /// Handles editable process-search input before global key bindings. + pub fn handle_text_input(&mut self, key: &KeyEvent) -> bool { + if !self.process_searching || key.kind != KeyEventKind::Press { + return false; } + match key.code { + KeyCode::Esc | KeyCode::Enter => self.process_searching = false, + KeyCode::Backspace => { + self.process_query.pop(); + self.scroll = 0; + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.process_query.clear(); + self.scroll = 0; + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.process_query.push(c); + self.scroll = 0; + } + _ => {} + } + true + } + + pub fn selected_process(&self) -> Option<&crate::sys::procs::Process> { + crate::sys::procs::view(&self.dynamic.procs, &self.process_query, self.process_tree) + .get(self.scroll) + .map(|(process, _)| *process) } fn switch_tab(&mut self, delta: isize) { diff --git a/src/cli.rs b/src/cli.rs index 6a81eb6..5988720 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -302,6 +302,9 @@ fn event_loop(app: &mut App) -> io::Result<()> { if event::poll(timeout)? { match event::read()? { Event::Key(key) => { + if app.handle_text_input(&key) { + continue; + } // Ctrl-C is honoured even if the user unbound quit. if is_interrupt(&key) { return Ok(()); diff --git a/src/config/model.rs b/src/config/model.rs index 3550956..d8ca9c4 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -470,6 +470,11 @@ pub struct Keys { pub freeze: Vec, pub info_details: Vec, pub errors: Vec, + pub search_processes: Vec, + pub clear_process_filter: Vec, + pub process_details: Vec, + pub process_tree: Vec, + pub signal_process: Vec, } impl Default for Keys { @@ -488,6 +493,11 @@ impl Default for Keys { freeze: b(&["space"]), info_details: b(&["i"]), errors: b(&["e"]), + search_processes: b(&["/"]), + clear_process_filter: b(&["ctrl+u"]), + process_details: b(&["enter"]), + process_tree: b(&["t"]), + signal_process: b(&["x"]), } } } diff --git a/src/sys/procs.rs b/src/sys/procs.rs index 13addb6..2b406a6 100644 --- a/src/sys/procs.rs +++ b/src/sys/procs.rs @@ -10,6 +10,7 @@ use crate::config::model::Processes as ProcCfg; #[derive(Debug, Clone, Default, PartialEq)] pub struct Process { pub pid: i32, + pub ppid: i32, pub name: String, /// The full command line, or the name again when it is unreadable. pub command: String, @@ -38,6 +39,7 @@ impl Process { struct Stat { name: String, state: char, + ppid: i32, ticks: u64, rss_pages: u64, threads: u64, @@ -59,6 +61,7 @@ fn parse_stat(text: &str) -> Option { Some(Stat { name, state: fields.first().and_then(|s| s.chars().next()).unwrap_or('?'), + ppid: fields.get(1).and_then(|f| f.parse().ok()).unwrap_or(0), ticks: get(14) + get(15), // utime + stime rss_pages: get(24), threads: get(20), @@ -166,6 +169,7 @@ impl Sampler { out.push(Process { pid, + ppid: stat.ppid, name: stat.name, command, state: stat.state, @@ -213,6 +217,69 @@ pub fn sort(procs: &mut [Process], by: ProcSort, ascending: bool) { }); } +/// Processes prepared for the interactive table, paired with their tree depth. +pub fn view<'a>(procs: &'a [Process], query: &str, tree: bool) -> Vec<(&'a Process, usize)> { + let query = query.trim().to_ascii_lowercase(); + let matches = |process: &Process| { + query.is_empty() + || process.name.to_ascii_lowercase().contains(&query) + || process.command.to_ascii_lowercase().contains(&query) + || process.pid.to_string().contains(&query) + }; + + if !tree { + return procs + .iter() + .filter(|process| matches(process)) + .map(|process| (process, 0)) + .collect(); + } + + use std::collections::{HashMap, HashSet}; + let known: HashSet = procs.iter().map(|process| process.pid).collect(); + let mut children: HashMap> = HashMap::new(); + let mut roots = Vec::new(); + for process in procs { + if process.ppid > 0 && known.contains(&process.ppid) && process.ppid != process.pid { + children.entry(process.ppid).or_default().push(process); + } else { + roots.push(process); + } + } + + fn walk<'a>( + process: &'a Process, + depth: usize, + children: &HashMap>, + seen: &mut HashSet, + out: &mut Vec<(&'a Process, usize)>, + ) { + if !seen.insert(process.pid) { + return; + } + out.push((process, depth)); + if let Some(nodes) = children.get(&process.pid) { + for child in nodes { + walk(child, depth.saturating_add(1), children, seen, out); + } + } + } + + let mut ordered = Vec::with_capacity(procs.len()); + let mut seen = HashSet::new(); + for root in roots { + walk(root, 0, &children, &mut seen, &mut ordered); + } + // Malformed parent cycles are still shown once. + for process in procs { + walk(process, 0, &children, &mut seen, &mut ordered); + } + ordered + .into_iter() + .filter(|(process, _)| matches(process)) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -226,6 +293,7 @@ mod tests { let s = parse_stat(STAT).unwrap(); assert_eq!(s.name, "my prog"); assert_eq!(s.state, 'S'); + assert_eq!(s.ppid, 1); assert_eq!(s.ticks, 200, "utime 150 + stime 50"); assert_eq!(s.threads, 7); assert_eq!(s.rss_pages, 2048); @@ -307,6 +375,22 @@ mod tests { assert_eq!(proc.display(true), "/usr/bin/prog --flag"); } + #[test] + fn view_filters_and_builds_a_tree() { + let mut parent = p(10, "shell", 0.0, 0); + parent.ppid = 1; + let mut child = p(20, "worker", 0.0, 0); + child.ppid = 10; + let procs = vec![child, parent]; + + let tree = view(&procs, "", true); + assert_eq!( + tree.iter().map(|(p, d)| (p.pid, *d)).collect::>(), + vec![(10, 0), (20, 1)] + ); + assert_eq!(view(&procs, "work", false)[0].0.pid, 20); + } + #[test] #[cfg(target_os = "linux")] fn reads_this_machine() { diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index 079230f..f19ffa4 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -108,6 +108,10 @@ pub fn footer(frame: &mut Frame, area: Rect, app: &App) { if app.tab == crate::config::enums::Tab::Overview { spans.extend(hint(&k.info_details, "info", app)); } + if app.tab == crate::config::enums::Tab::Processes { + spans.extend(hint(&k.search_processes, "filter", app)); + spans.extend(hint(&k.process_details, "details", app)); + } spans.extend(hint(&k.scroll_down, "scroll", app)); frame.render_widget( @@ -186,7 +190,7 @@ pub fn help(frame: &mut Frame, area: Rect, app: &App) { let theme = &app.theme; let k = &app.config.keys; - let entries: [(&str, &[KeyBinding]); 12] = [ + let entries: [(&str, &[KeyBinding]); 17] = [ ("quit", &k.quit), ("this help", &k.help), ("reload config", &k.reload), @@ -199,6 +203,11 @@ pub fn help(frame: &mut Frame, area: Rect, app: &App) { ("freeze / resume", &k.freeze), ("full system information", &k.info_details), ("error details", &k.errors), + ("filter processes", &k.search_processes), + ("clear process filter", &k.clear_process_filter), + ("selected process details", &k.process_details), + ("toggle process tree", &k.process_tree), + ("signal selected process", &k.signal_process), ]; let key_column = entries @@ -260,7 +269,7 @@ pub fn help(frame: &mut Frame, area: Rect, app: &App) { } fn text_popup(frame: &mut Frame, area: Rect, app: &App, title: &str, lines: Vec>) { - let width = area.width.saturating_sub(4).min(100).max(1); + let width = area.width.saturating_sub(4).clamp(1, 100); let height = (lines.len() as u16 + 2) .min(area.height.saturating_sub(2)) .max(1); @@ -302,6 +311,45 @@ pub fn errors(frame: &mut Frame, area: Rect, app: &App) { text_popup(frame, area, app, " Errors — e/esc close ", lines); } +pub fn process_details(frame: &mut Frame, area: Rect, app: &App) { + let Some(process) = app.selected_process() else { + return; + }; + let lines = vec![ + Line::from(format!("Name {}", process.name)), + Line::from(format!("PID {}", process.pid)), + Line::from(format!("Parent {}", process.ppid)), + Line::from(format!("UID {}", process.uid)), + Line::from(format!("State {}", process.state)), + Line::from(format!("Threads {}", process.threads)), + Line::from(format!("CPU {:.1}%", process.cpu)), + Line::from(format!( + "Memory {}", + crate::util::human_bytes(process.memory) + )), + Line::from(""), + Line::from(format!("Command {}", process.display(true))), + ]; + text_popup(frame, area, app, " Process — enter/esc close ", lines); +} + +pub fn signal_confirm(frame: &mut Frame, area: Rect, app: &App) { + let Some(process) = app.selected_process() else { + return; + }; + text_popup( + frame, + area, + app, + " Send SIGTERM? ", + vec![ + Line::from(format!("Terminate {} ({})?", process.name, process.pid)), + Line::from(""), + Line::from("y send SIGTERM n/esc cancel"), + ], + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f225b62..5918096 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -52,6 +52,10 @@ pub fn draw(frame: &mut Frame, app: &App) { chrome::info_details(frame, area, app); } else if app.show_errors { chrome::errors(frame, area, app); + } else if app.show_process_details { + chrome::process_details(frame, area, app); + } else if app.show_signal_confirm { + chrome::signal_confirm(frame, area, app); } } diff --git a/src/ui/tables.rs b/src/ui/tables.rs index ef29545..e5f682e 100644 --- a/src/ui/tables.rs +++ b/src/ui/tables.rs @@ -2,7 +2,9 @@ use ratatui::layout::{Constraint, Rect}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Cell, Paragraph, Row, Table}; +use ratatui::widgets::{ + Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, +}; use ratatui::Frame; use crate::app::App; @@ -30,15 +32,23 @@ fn empty(frame: &mut Frame, area: Rect, app: &App, message: &str) { /// `scroll` is ignored and the title simplified when `full` is false, which is /// how the overview panel shows a short top-N list. pub fn processes(frame: &mut Frame, area: Rect, app: &App, scroll: usize, full: bool) { + let view = crate::sys::procs::view(&app.dynamic.procs, &app.process_query, app.process_tree); let title = format!( - " Processes — {} by {}{} ", + " Processes — {}/{} by {}{}{}{} ", + view.len(), app.dynamic.proc_total, app.sort, if app.config.processes.ascending { " ▲" } else { " ▼" - } + }, + if app.process_tree { " · tree" } else { "" }, + if app.process_query.is_empty() { + String::new() + } else { + format!(" · /{}", app.process_query) + }, ); let block = super::panel(&app.theme, &title); let inner = block.inner(area); @@ -47,8 +57,17 @@ pub fn processes(frame: &mut Frame, area: Rect, app: &App, scroll: usize, full: if inner.width == 0 || inner.height == 0 { return; } - if app.dynamic.procs.is_empty() { - empty(frame, inner, app, "no processes visible"); + if view.is_empty() { + empty( + frame, + inner, + app, + if app.process_query.is_empty() { + "no processes visible" + } else { + "no processes match the filter; ctrl+u clears it" + }, + ); return; } @@ -63,29 +82,43 @@ pub fn processes(frame: &mut Frame, area: Rect, app: &App, scroll: usize, full: } else { app.config.processes.count.min(visible) }; + let selected = scroll.min(view.len().saturating_sub(1)); let offset = if full { - clamp_scroll(scroll, app.dynamic.procs.len(), visible) + selected.saturating_sub(visible.saturating_sub(1)) } else { 0 }; let theme = &app.theme; - let rows: Vec = app - .dynamic - .procs + let rows: Vec = view .iter() .skip(offset) .take(limit) - .map(|p| { + .enumerate() + .map(|(index, (p, depth))| { let cpu_color = theme.level( p.cpu, app.config.meters.warn_at, app.config.meters.critical_at, ); Row::new(vec![ + Cell::from(if full && offset + index == selected { + "›" + } else { + "" + }) + .style(theme.accent.fg()), Cell::from(p.pid.to_string()).style(theme.muted.fg()), - Cell::from(p.display(app.config.processes.full_command).to_string()) - .style(theme.value.fg()), + Cell::from(format!( + "{}{}", + if app.process_tree { + " ".repeat(*depth) + } else { + String::new() + }, + p.display(app.config.processes.full_command) + )) + .style(theme.value.fg()), Cell::from(format!("{:>5.1}", p.cpu)).style(cpu_color.fg()), Cell::from(human_bytes(p.memory)).style(theme.foreground.fg()), Cell::from(p.state.to_string()).style(theme.muted.fg()), @@ -93,7 +126,7 @@ pub fn processes(frame: &mut Frame, area: Rect, app: &App, scroll: usize, full: }) .collect(); - let header = Row::new(vec!["PID", "COMMAND", "CPU%", "MEM", "S"]).style( + let header = Row::new(vec!["", "PID", "COMMAND", "CPU%", "MEM", "S"]).style( theme .accent .fg() @@ -101,6 +134,7 @@ pub fn processes(frame: &mut Frame, area: Rect, app: &App, scroll: usize, full: ); let widths = [ + Constraint::Length(1), Constraint::Length(7), Constraint::Fill(1), Constraint::Length(6), @@ -115,6 +149,15 @@ pub fn processes(frame: &mut Frame, area: Rect, app: &App, scroll: usize, full: .style(super::surface(theme)), inner, ); + + if full && view.len() > visible { + let mut state = ScrollbarState::new(view.len()).position(selected); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight), + inner, + &mut state, + ); + } } /// The filesystem table. From 0ddef3360dc92ac916a97f745caafe9cbba9efbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tuna=20K=C4=B1l=C4=B1=C3=A7?= Date: Tue, 15 Sep 2026 03:11:52 +0300 Subject: [PATCH 3/9] feat(disks): collect io throughput --- assets/config.template.toml | 2 +- src/app.rs | 11 ++- src/config/enums.rs | 3 +- src/sys/disk_io.rs | 145 ++++++++++++++++++++++++++++++++++++ src/sys/mod.rs | 5 ++ src/ui/graphs.rs | 41 +++++++++- 6 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 src/sys/disk_io.rs diff --git a/assets/config.template.toml b/assets/config.template.toml index add729e..7c640e5 100644 --- a/assets/config.template.toml +++ b/assets/config.template.toml @@ -169,7 +169,7 @@ label_width = 8 per_core = false [graphs] -# Time series. Available: cpu, memory, swap, network, disk, load. +# Time series. Available: cpu, memory, swap, network, disk, disk_io, load. items = ["cpu", "memory", "network"] # sparkline, braille, or bars. style = "sparkline" diff --git a/src/app.rs b/src/app.rs index c902178..b21780f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -93,6 +93,8 @@ pub struct History { pub memory: Ring, pub swap: Ring, pub disk: Ring, + pub disk_read: Ring, + pub disk_write: Ring, pub load: Ring, pub net_rx: Ring, pub net_tx: Ring, @@ -105,18 +107,22 @@ impl History { memory: Ring::new(capacity), swap: Ring::new(capacity), disk: Ring::new(capacity), + disk_read: Ring::new(capacity), + disk_write: Ring::new(capacity), load: Ring::new(capacity), net_rx: Ring::new(capacity), net_tx: Ring::new(capacity), } } - fn each_mut(&mut self) -> [&mut Ring; 7] { + fn each_mut(&mut self) -> [&mut Ring; 9] { [ &mut self.cpu, &mut self.memory, &mut self.swap, &mut self.disk, + &mut self.disk_read, + &mut self.disk_write, &mut self.load, &mut self.net_rx, &mut self.net_tx, @@ -139,6 +145,9 @@ impl History { .map(|p| p.percent()) .unwrap_or(0.0), ); + let (read, write) = crate::sys::disk_io::total_rate(&d.disk_io); + self.disk_read.push(read); + self.disk_write.push(write); self.load.push(d.load[0]); let (rx, tx) = d.net_rates(); self.net_rx.push(rx); diff --git a/src/config/enums.rs b/src/config/enums.rs index f5e9e40..ec0a62c 100644 --- a/src/config/enums.rs +++ b/src/config/enums.rs @@ -162,7 +162,8 @@ string_enum! { Memory => "memory" | "ram", Swap => "swap", Network => "network" | "net", - Disk => "disk" | "disk_io", + Disk => "disk" | "disk_usage", + DiskIo => "disk_io" | "io", Load => "load" | "load_avg", } } diff --git a/src/sys/disk_io.rs b/src/sys/disk_io.rs new file mode 100644 index 0000000..e37de2e --- /dev/null +++ b/src/sys/disk_io.rs @@ -0,0 +1,145 @@ +//! Block-device throughput from `/proc/diskstats`. + +use std::collections::{HashMap, HashSet}; +use std::fs; + +/// Live I/O for one whole block device. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DeviceIo { + pub name: String, + pub read_bytes: u64, + pub write_bytes: u64, + pub read_rate: f64, + pub write_rate: f64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct Counters { + read_sectors: u64, + write_sectors: u64, +} + +fn parse(text: &str) -> HashMap { + text.lines() + .filter_map(|line| { + let fields: Vec<&str> = line.split_whitespace().collect(); + let name = fields.get(2)?.to_string(); + let read_sectors = fields.get(5)?.parse().ok()?; + let write_sectors = fields.get(9)?.parse().ok()?; + Some(( + name, + Counters { + read_sectors, + write_sectors, + }, + )) + }) + .collect() +} + +fn whole_devices() -> HashSet { + let Ok(entries) = fs::read_dir("/sys/block") else { + return HashSet::new(); + }; + entries + .flatten() + .filter_map(|entry| entry.file_name().to_str().map(str::to_string)) + .filter(|name| { + !name.starts_with("loop") && !name.starts_with("ram") && !name.starts_with("zram") + }) + .collect() +} + +/// Turns monotonic sector counters into byte rates. +pub struct Sampler { + prev: HashMap, +} + +impl Sampler { + pub fn new() -> Self { + Self { + prev: HashMap::new(), + } + } + + pub fn sample(&mut self, elapsed: f64) -> Vec { + let text = fs::read_to_string("/proc/diskstats").unwrap_or_default(); + let counters = parse(&text); + let wanted = whole_devices(); + let mut out = Vec::new(); + + for (name, current) in &counters { + if !wanted.is_empty() && !wanted.contains(name) { + continue; + } + // Linux diskstats sectors are always expressed as 512-byte units. + const SECTOR: u64 = 512; + let (read_rate, write_rate) = match self.prev.get(name) { + Some(previous) if elapsed > 0.0 => ( + current.read_sectors.saturating_sub(previous.read_sectors) as f64 + * SECTOR as f64 + / elapsed, + current.write_sectors.saturating_sub(previous.write_sectors) as f64 + * SECTOR as f64 + / elapsed, + ), + _ => (0.0, 0.0), + }; + out.push(DeviceIo { + name: name.clone(), + read_bytes: current.read_sectors.saturating_mul(SECTOR), + write_bytes: current.write_sectors.saturating_mul(SECTOR), + read_rate, + write_rate, + }); + } + + self.prev = counters; + out.sort_by(|a, b| a.name.cmp(&b.name)); + out + } +} + +impl Default for Sampler { + fn default() -> Self { + Self::new() + } +} + +pub fn total_rate(devices: &[DeviceIo]) -> (f64, f64) { + devices.iter().fold((0.0, 0.0), |(read, write), device| { + (read + device.read_rate, write + device.write_rate) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const STATS: &str = "259 0 nvme0n1 10 0 200 0 20 0 400 0 0 0 0 0 0 0 0\n\ + 259 1 nvme0n1p1 2 0 40 0 3 0 60 0 0 0 0 0 0 0 0\n"; + + #[test] + fn parses_sector_counters() { + let parsed = parse(STATS); + assert_eq!(parsed["nvme0n1"].read_sectors, 200); + assert_eq!(parsed["nvme0n1"].write_sectors, 400); + } + + #[test] + fn totals_device_rates() { + let devices = vec![ + DeviceIo { + read_rate: 10.0, + write_rate: 20.0, + ..Default::default() + }, + DeviceIo { + read_rate: 2.0, + write_rate: 3.0, + ..Default::default() + }, + ]; + assert_eq!(total_rate(&devices), (12.0, 23.0)); + } +} diff --git a/src/sys/mod.rs b/src/sys/mod.rs index 95e2d82..d59c55b 100644 --- a/src/sys/mod.rs +++ b/src/sys/mod.rs @@ -9,6 +9,7 @@ pub mod battery; pub mod cpu; pub mod desktop; pub mod disk; +pub mod disk_io; pub mod gpu; pub mod host; pub mod mem; @@ -81,6 +82,7 @@ pub struct Dynamic { pub cpu: cpu::CpuUsage, pub mem: mem::Memory, pub disks: Vec, + pub disk_io: Vec, pub nets: Vec, pub battery: Option, pub temps: Vec, @@ -126,6 +128,7 @@ impl Dynamic { pub struct Sampler { cpu: cpu::Sampler, net: net::Sampler, + disk_io: disk_io::Sampler, procs: procs::Sampler, last: Option, } @@ -135,6 +138,7 @@ impl Sampler { Self { cpu: cpu::Sampler::new(), net: net::Sampler::new(), + disk_io: disk_io::Sampler::new(), procs: procs::Sampler::new(), last: None, } @@ -160,6 +164,7 @@ impl Sampler { cpu: self.cpu.sample(), mem: mem::Memory::load(), disks: disk::list(&cfg.disks), + disk_io: self.disk_io.sample(elapsed), nets, battery: battery::load(), temps: temp::list(), diff --git a/src/ui/graphs.rs b/src/ui/graphs.rs index bb7b388..b1c9e5e 100644 --- a/src/ui/graphs.rs +++ b/src/ui/graphs.rs @@ -29,7 +29,7 @@ fn ceiling_for(kind: GraphKind, ring: &Ring, app: &App) -> f64 { match kind { GraphKind::Cpu | GraphKind::Memory | GraphKind::Swap | GraphKind::Disk => 100.0, GraphKind::Load => (app.statics.cpu.threads.max(1) as f64).max(ring.max()), - GraphKind::Network => ring.max().max(64.0 * 1024.0), + GraphKind::Network | GraphKind::DiskIo => ring.max().max(64.0 * 1024.0), } } @@ -71,7 +71,7 @@ fn collect<'a>(app: &'a App) -> Vec> { color: theme.warn, }], GraphKind::Disk => vec![Series { - title: percent_title("Disk", &h.disk), + title: percent_title("Disk use", &h.disk), ring: &h.disk, ceiling: ceiling_for(*kind, &h.disk, app), color: theme.accent, @@ -86,6 +86,39 @@ fn collect<'a>(app: &'a App) -> Vec> { ceiling: ceiling_for(*kind, &h.load, app), color: theme.critical, }], + GraphKind::DiskIo => { + let ceiling = h.disk_read.max().max(h.disk_write.max()).max(64.0 * 1024.0); + vec![ + Series { + title: if show_stats { + format!( + " read {} (peak {}) ", + human_rate(h.disk_read.last()), + human_rate(h.disk_read.max()) + ) + } else { + " read ".to_string() + }, + ring: &h.disk_read, + ceiling, + color: theme.good, + }, + Series { + title: if show_stats { + format!( + " write {} (peak {}) ", + human_rate(h.disk_write.last()), + human_rate(h.disk_write.max()) + ) + } else { + " write ".to_string() + }, + ring: &h.disk_write, + ceiling, + color: theme.graph_secondary, + }, + ] + } // Receive and transmit share a scale so the two are comparable. GraphKind::Network => { let ceiling = h.net_rx.max().max(h.net_tx.max()).max(64.0 * 1024.0); @@ -281,7 +314,7 @@ mod tests { let mut config = Config::default(); config.graphs.items = GraphKind::ALL.to_vec(); let app = app_with(config); - // Network counts twice. - assert_eq!(collect(&app).len(), GraphKind::ALL.len() + 1); + // Network and disk I/O each expand into two series. + assert_eq!(collect(&app).len(), GraphKind::ALL.len() + 2); } } From f9ade3a8b81d88fa907e16d7598ffd57a9a843ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tuna=20K=C4=B1l=C4=B1=C3=A7?= Date: Tue, 15 Sep 2026 06:36:09 +0300 Subject: [PATCH 4/9] feat(output): add plain and json formats --- Cargo.lock | 20 ++++++++ Cargo.toml | 1 + README.md | 2 + src/cli.rs | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 150 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index caf1966..a8a33da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1212,6 +1212,7 @@ dependencies = [ "libc", "ratatui", "serde", + "serde_json", "toml", ] @@ -1340,6 +1341,19 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1905,3 +1919,9 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 5a5aee0..d1398e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ dirs = "6.0" libc = "0.2" ratatui = "0.30" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" toml = "1.1" [build-dependencies] diff --git a/README.md b/README.md index 34ef9cf..7ac33ff 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ ratfetch --once # one frame, like a classic fetch tool ratfetch --logo nixos # override the detected logo ratfetch --theme gruvbox-dark ratfetch --interval 250 # four samples a second +ratfetch --format json # one machine-readable snapshot +ratfetch --format plain # clean line-oriented output ratfetch --list-logos | fzf # browse what is bundled ``` diff --git a/src/cli.rs b/src/cli.rs index 5988720..5a50022 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use std::process::ExitCode; use std::time::Instant; -use clap::Parser; +use clap::{Parser, ValueEnum}; use crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers, }; @@ -19,6 +19,19 @@ use ratatui::{Terminal, TerminalOptions, Viewport}; use crate::app::{Action, App}; use crate::config::{self, LoadOptions}; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +pub enum OutputFormat { + /// TUI on a terminal, plain text when redirected. + #[default] + Auto, + /// The interactive terminal interface, including ANSI control sequences. + Tui, + /// Stable, line-oriented text without terminal control sequences. + Plain, + /// A complete machine-readable snapshot. + Json, +} + /// A live, always-updating system fetch for the terminal. #[derive(Debug, Parser)] #[command(name = "ratfetch", version, about, long_about = None)] @@ -51,6 +64,10 @@ pub struct Args { #[arg(short = '1', long)] pub once: bool, + /// Output format. Non-terminal output defaults to plain. + #[arg(long, value_enum, default_value_t)] + pub format: OutputFormat, + /// Quit after this many seconds. #[arg(long, value_name = "SECS")] pub exit_after: Option, @@ -113,6 +130,11 @@ pub fn run() -> ExitCode { match dispatch(&args) { Ok(code) => code, Err(e) => { + if e.downcast_ref::() + .is_some_and(|error| error.kind() == io::ErrorKind::BrokenPipe) + { + return ExitCode::SUCCESS; + } eprintln!("ratfetch: {e}"); ExitCode::FAILURE } @@ -166,14 +188,114 @@ fn dispatch(args: &Args) -> Result> { } let app = App::new(loaded, options); - if args.once { - draw_once(app)?; - } else { - run_loop(app)?; + let format = match args.format { + OutputFormat::Auto if !io::stdout().is_terminal() => OutputFormat::Plain, + OutputFormat::Auto => OutputFormat::Tui, + explicit => explicit, + }; + match format { + OutputFormat::Plain => write_plain(io::stdout().lock(), &app)?, + OutputFormat::Json => write_json(io::stdout().lock(), &app)?, + OutputFormat::Tui | OutputFormat::Auto if args.once => draw_once(app)?, + OutputFormat::Tui | OutputFormat::Auto => run_loop(app)?, } Ok(ExitCode::SUCCESS) } +fn write_plain(mut out: impl Write, app: &App) -> io::Result<()> { + let s = &app.statics; + let d = &app.dynamic; + let (rx, tx) = d.net_rates(); + let (disk_read, disk_write) = crate::sys::disk_io::total_rate(&d.disk_io); + for (key, value) in [ + ("user", s.username.clone()), + ("host", s.hostname.clone()), + ("os", s.os_name.clone()), + ("kernel", s.kernel.clone()), + ("uptime", crate::util::human_uptime(d.uptime)), + ("cpu", s.cpu.label(d.cpu.freq_mhz)), + ("cpu_percent", format!("{:.1}", d.cpu.total)), + ("memory_used", d.mem.used.to_string()), + ("memory_total", d.mem.total.to_string()), + ("memory_percent", format!("{:.1}", d.mem.percent())), + ("network_rx_bytes_per_second", format!("{rx:.0}")), + ("network_tx_bytes_per_second", format!("{tx:.0}")), + ("disk_read_bytes_per_second", format!("{disk_read:.0}")), + ("disk_write_bytes_per_second", format!("{disk_write:.0}")), + ("processes", d.proc_total.to_string()), + ] { + writeln!(out, "{key}: {value}")?; + } + for disk in &d.disks { + writeln!( + out, + "filesystem: {} {} {} {}", + disk.mount, disk.used, disk.total, disk.fstype + )?; + } + for interface in &d.nets { + writeln!( + out, + "interface: {} {} {} {:.0} {:.0}", + interface.name, + interface.state, + interface.ipv4.as_deref().unwrap_or("-"), + interface.rx_rate, + interface.tx_rate + )?; + } + Ok(()) +} + +fn write_json(mut out: impl Write, app: &App) -> io::Result<()> { + let s = &app.statics; + let d = &app.dynamic; + let value = serde_json::json!({ + "identity": { "user": s.username, "hostname": s.hostname }, + "system": { + "os": s.os_name, "distro_id": s.distro_id, "kernel": s.kernel, + "host_model": s.host_model, "uptime_seconds": d.uptime, + "shell": s.shell, "terminal": s.terminal, "desktop": s.de, + "window_manager": s.wm, "resolution": s.resolution, "locale": s.locale, + "packages": s.packages, + }, + "cpu": { + "model": s.cpu.model, "physical_cores": s.cpu.cores, + "threads": s.cpu.threads, "frequency_mhz": d.cpu.freq_mhz, + "percent": d.cpu.total, "per_core_percent": d.cpu.per_core, + }, + "memory": { + "total_bytes": d.mem.total, "used_bytes": d.mem.used, + "available_bytes": d.mem.available, "percent": d.mem.percent(), + "swap_total_bytes": d.mem.swap_total, "swap_used_bytes": d.mem.swap_used, + }, + "filesystems": d.disks.iter().map(|disk| serde_json::json!({ + "device": disk.device, "mount": disk.mount, "type": disk.fstype, + "total_bytes": disk.total, "used_bytes": disk.used, + "available_bytes": disk.available, + })).collect::>(), + "disk_io": d.disk_io.iter().map(|device| serde_json::json!({ + "device": device.name, "read_bytes": device.read_bytes, + "write_bytes": device.write_bytes, "read_bytes_per_second": device.read_rate, + "write_bytes_per_second": device.write_rate, + })).collect::>(), + "network": d.nets.iter().map(|interface| serde_json::json!({ + "interface": interface.name, "state": interface.state, "ipv4": interface.ipv4, + "rx_bytes": interface.rx_bytes, "tx_bytes": interface.tx_bytes, + "rx_bytes_per_second": interface.rx_rate, + "tx_bytes_per_second": interface.tx_rate, + })).collect::>(), + "processes": d.procs.iter().map(|process| serde_json::json!({ + "pid": process.pid, "ppid": process.ppid, "uid": process.uid, + "name": process.name, "command": process.command, "state": process.state.to_string(), + "threads": process.threads, "cpu_percent": process.cpu, + "memory_bytes": process.memory, + })).collect::>(), + }); + serde_json::to_writer_pretty(&mut out, &value).map_err(io::Error::other)?; + writeln!(out) +} + /// `--check-config`: report what would be loaded, without starting the UI. fn check(options: &LoadOptions) -> ExitCode { match config::load(options) { From 1de5f46e7b8e5518136fae9fef593876d483bcda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tuna=20K=C4=B1l=C4=B1=C3=A7?= Date: Tue, 15 Sep 2026 09:08:41 +0300 Subject: [PATCH 5/9] feat(gpu): collect live telemetry --- assets/config.template.toml | 4 +- src/app.rs | 11 +++++- src/cli.rs | 12 ++++++ src/config/enums.rs | 2 + src/sys/gpu.rs | 78 ++++++++++++++++++++++++++++++++++++- src/sys/mod.rs | 2 + src/ui/graphs.rs | 10 ++++- src/ui/header.rs | 26 ++++++++++++- src/ui/meters.rs | 18 +++++++++ 9 files changed, 156 insertions(+), 7 deletions(-) diff --git a/assets/config.template.toml b/assets/config.template.toml index 7c640e5..5693843 100644 --- a/assets/config.template.toml +++ b/assets/config.template.toml @@ -154,7 +154,7 @@ bold_title = true hide_empty = true [meters] -# Live bars. Available: cpu, memory, swap, disk, battery, load. +# Live bars. Available: cpu, memory, swap, disk, battery, load, gpu. items = ["cpu", "memory", "swap", "disk"] # bar, line, blocks, or dots. style = "bar" @@ -169,7 +169,7 @@ label_width = 8 per_core = false [graphs] -# Time series. Available: cpu, memory, swap, network, disk, disk_io, load. +# Time series. Available: cpu, memory, swap, network, disk, disk_io, load, gpu. items = ["cpu", "memory", "network"] # sparkline, braille, or bars. style = "sparkline" diff --git a/src/app.rs b/src/app.rs index b21780f..4ef3e9a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -95,6 +95,7 @@ pub struct History { pub disk: Ring, pub disk_read: Ring, pub disk_write: Ring, + pub gpu: Ring, pub load: Ring, pub net_rx: Ring, pub net_tx: Ring, @@ -109,13 +110,14 @@ impl History { disk: Ring::new(capacity), disk_read: Ring::new(capacity), disk_write: Ring::new(capacity), + gpu: Ring::new(capacity), load: Ring::new(capacity), net_rx: Ring::new(capacity), net_tx: Ring::new(capacity), } } - fn each_mut(&mut self) -> [&mut Ring; 9] { + fn each_mut(&mut self) -> [&mut Ring; 10] { [ &mut self.cpu, &mut self.memory, @@ -123,6 +125,7 @@ impl History { &mut self.disk, &mut self.disk_read, &mut self.disk_write, + &mut self.gpu, &mut self.load, &mut self.net_rx, &mut self.net_tx, @@ -148,6 +151,12 @@ impl History { let (read, write) = crate::sys::disk_io::total_rate(&d.disk_io); self.disk_read.push(read); self.disk_write.push(write); + self.gpu.push( + d.gpus + .iter() + .find_map(|gpu| gpu.usage_percent) + .unwrap_or(0.0), + ); self.load.push(d.load[0]); let (rx, tx) = d.net_rates(); self.net_rx.push(rx); diff --git a/src/cli.rs b/src/cli.rs index 5a50022..d72d3c4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -269,6 +269,18 @@ fn write_json(mut out: impl Write, app: &App) -> io::Result<()> { "available_bytes": d.mem.available, "percent": d.mem.percent(), "swap_total_bytes": d.mem.swap_total, "swap_used_bytes": d.mem.swap_used, }, + "gpus": s.gpus.iter().map(|gpu| { + let live = d.gpus.iter().find(|stats| stats.card == gpu.card); + serde_json::json!({ + "card": gpu.card, "vendor": gpu.vendor, "model": gpu.model, + "driver": gpu.driver, + "percent": live.and_then(|stats| stats.usage_percent), + "memory_used_bytes": live.and_then(|stats| stats.memory_used), + "memory_total_bytes": live.and_then(|stats| stats.memory_total), + "temperature_celsius": live.and_then(|stats| stats.temperature_celsius), + "power_watts": live.and_then(|stats| stats.power_watts), + }) + }).collect::>(), "filesystems": d.disks.iter().map(|disk| serde_json::json!({ "device": disk.device, "mount": disk.mount, "type": disk.fstype, "total_bytes": disk.total, "used_bytes": disk.used, diff --git a/src/config/enums.rs b/src/config/enums.rs index ec0a62c..02a835a 100644 --- a/src/config/enums.rs +++ b/src/config/enums.rs @@ -152,6 +152,7 @@ string_enum! { Disk => "disk", Battery => "battery", Load => "load" | "load_avg", + Gpu => "gpu", } } @@ -165,6 +166,7 @@ string_enum! { Disk => "disk" | "disk_usage", DiskIo => "disk_io" | "io", Load => "load" | "load_avg", + Gpu => "gpu", } } diff --git a/src/sys/gpu.rs b/src/sys/gpu.rs index 7b8dc3a..2e760b8 100644 --- a/src/sys/gpu.rs +++ b/src/sys/gpu.rs @@ -9,12 +9,24 @@ use super::read_trimmed; /// A graphics adapter. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Gpu { + pub card: String, pub vendor: String, pub model: String, /// The kernel driver bound to it, e.g. `amdgpu`. pub driver: String, } +/// Live telemetry exposed by a DRM driver's sysfs nodes. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct GpuStats { + pub card: String, + pub usage_percent: Option, + pub memory_used: Option, + pub memory_total: Option, + pub temperature_celsius: Option, + pub power_watts: Option, +} + impl Gpu { /// `AMD Radeon RX 7900 XTX`, or the best available approximation. pub fn label(&self) -> String { @@ -65,10 +77,14 @@ pub fn list() -> Vec { for card in cards { let device = card.path().join("device"); - let Some(gpu) = read_card(&device, db.as_ref()) else { + let Some(mut gpu) = read_card(&device, db.as_ref()) else { continue; }; - if !out.contains(&gpu) { + gpu.card = card.file_name().to_string_lossy().into_owned(); + if !out + .iter() + .any(|known| known.vendor == gpu.vendor && known.model == gpu.model) + { out.push(gpu); } } @@ -96,12 +112,68 @@ fn read_card(device: &Path, db: Option<&PciIds>) -> Option { .unwrap_or_default(); Some(Gpu { + card: String::new(), vendor, model, driver, }) } +/// Reads every live field the active kernel drivers expose. +pub fn stats() -> Vec { + let Ok(entries) = fs::read_dir("/sys/class/drm") else { + return Vec::new(); + }; + let mut out = Vec::new(); + for entry in entries.flatten() { + let card = entry.file_name().to_string_lossy().into_owned(); + if !card.starts_with("card") || card.contains('-') { + continue; + } + let device = entry.path().join("device"); + let usage_percent = read_trimmed(device.join("gpu_busy_percent")) + .and_then(|value| value.parse::().ok()) + .map(|value| value.clamp(0.0, 100.0)); + let memory_used = super::read_u64(device.join("mem_info_vram_used")); + let memory_total = super::read_u64(device.join("mem_info_vram_total")); + + let mut temperature_celsius = None; + let mut power_watts = None; + if let Ok(hwmons) = fs::read_dir(device.join("hwmon")) { + for hwmon in hwmons.flatten() { + if temperature_celsius.is_none() { + temperature_celsius = super::read_u64(hwmon.path().join("temp1_input")) + .map(|milli| milli as f64 / 1000.0) + .filter(|value| (-50.0..=150.0).contains(value)); + } + if power_watts.is_none() { + power_watts = super::read_u64(hwmon.path().join("power1_average")) + .or_else(|| super::read_u64(hwmon.path().join("power1_input"))) + .map(|microwatts| microwatts as f64 / 1_000_000.0); + } + } + } + + if usage_percent.is_some() + || memory_used.is_some() + || memory_total.is_some() + || temperature_celsius.is_some() + || power_watts.is_some() + { + out.push(GpuStats { + card, + usage_percent, + memory_used, + memory_total, + temperature_celsius, + power_watts, + }); + } + } + out.sort_by(|a, b| a.card.cmp(&b.card)); + out +} + /// Reads a `0x10de`-style sysfs id. fn read_hex_id(path: impl AsRef) -> Option { let text = read_trimmed(path)?; @@ -213,6 +285,7 @@ mod tests { #[test] fn label_degrades_gracefully() { let g = Gpu { + card: "card0".into(), vendor: "AMD".into(), model: "Navi 31".into(), driver: "amdgpu".into(), @@ -232,5 +305,6 @@ mod tests { fn listing_never_panics() { // Headless machines and containers have no /sys/class/drm. let _ = list(); + let _ = stats(); } } diff --git a/src/sys/mod.rs b/src/sys/mod.rs index d59c55b..62bbd4b 100644 --- a/src/sys/mod.rs +++ b/src/sys/mod.rs @@ -86,6 +86,7 @@ pub struct Dynamic { pub nets: Vec, pub battery: Option, pub temps: Vec, + pub gpus: Vec, pub load: [f64; 3], pub procs: Vec, pub proc_total: usize, @@ -168,6 +169,7 @@ impl Sampler { nets, battery: battery::load(), temps: temp::list(), + gpus: gpu::stats(), load: os::load_average(), procs: self.procs.sample(elapsed, &cfg.processes), proc_total: self.procs.last_total, diff --git a/src/ui/graphs.rs b/src/ui/graphs.rs index b1c9e5e..3c28382 100644 --- a/src/ui/graphs.rs +++ b/src/ui/graphs.rs @@ -27,7 +27,9 @@ struct Series<'a> { /// floor that keeps an idle graph from looking busy. fn ceiling_for(kind: GraphKind, ring: &Ring, app: &App) -> f64 { match kind { - GraphKind::Cpu | GraphKind::Memory | GraphKind::Swap | GraphKind::Disk => 100.0, + GraphKind::Cpu | GraphKind::Memory | GraphKind::Swap | GraphKind::Disk | GraphKind::Gpu => { + 100.0 + } GraphKind::Load => (app.statics.cpu.threads.max(1) as f64).max(ring.max()), GraphKind::Network | GraphKind::DiskIo => ring.max().max(64.0 * 1024.0), } @@ -64,6 +66,12 @@ fn collect<'a>(app: &'a App) -> Vec> { ceiling: ceiling_for(*kind, &h.memory, app), color: theme.graph_secondary, }], + GraphKind::Gpu => vec![Series { + title: percent_title("GPU", &h.gpu), + ring: &h.gpu, + ceiling: 100.0, + color: theme.accent, + }], GraphKind::Swap => vec![Series { title: percent_title("Swap", &h.swap), ring: &h.swap, diff --git a/src/ui/header.rs b/src/ui/header.rs index 0e5246c..6e1730b 100644 --- a/src/ui/header.rs +++ b/src/ui/header.rs @@ -57,7 +57,31 @@ fn rows(app: &App) -> Vec> { "GPU", s.gpus .iter() - .map(|g| g.label()) + .map(|g| { + let mut label = g.label(); + if let Some(stats) = d.gpus.iter().find(|stats| stats.card == g.card) { + let mut live = Vec::new(); + if let Some(usage) = stats.usage_percent { + live.push(format!("{usage:.0}%")); + } + if let (Some(used), Some(total)) = + (stats.memory_used, stats.memory_total) + { + live.push(format!( + "{} / {}", + human_bytes(used), + human_bytes(total) + )); + } + if let Some(temp) = stats.temperature_celsius { + live.push(format!("{temp:.0} °C")); + } + if !live.is_empty() { + label.push_str(&format!(" ({})", live.join(", "))); + } + } + label + }) .collect::>() .join(", "), ), diff --git a/src/ui/meters.rs b/src/ui/meters.rs index 0b53e4d..9e4e71b 100644 --- a/src/ui/meters.rs +++ b/src/ui/meters.rs @@ -135,6 +135,24 @@ fn collect(app: &App) -> Vec { detail: format!("{:.2} {:.2} {:.2}", d.load[0], d.load[1], d.load[2]), }); } + MeterKind::Gpu => { + if let Some(percent) = d.gpus.iter().find_map(|gpu| gpu.usage_percent) { + let stats = d.gpus.iter().find(|gpu| gpu.usage_percent.is_some()); + let detail = stats + .and_then(|gpu| match (gpu.memory_used, gpu.memory_total) { + (Some(used), Some(total)) => { + Some(format!("{} / {}", human_bytes(used), human_bytes(total))) + } + _ => gpu.temperature_celsius.map(|temp| format!("{temp:.0} °C")), + }) + .unwrap_or_default(); + out.push(Meter { + label: "GPU".into(), + percent, + detail, + }); + } + } } } From bacf8396dc0abf7fd2553ebd8f0b05162b217e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tuna=20K=C4=B1l=C4=B1=C3=A7?= Date: Tue, 15 Sep 2026 11:47:26 +0300 Subject: [PATCH 6/9] feat(network): show ipv6 addresses --- assets/config.template.toml | 3 +- src/cli.rs | 6 ++- src/config/enums.rs | 1 + src/sys/mod.rs | 3 ++ src/sys/net.rs | 82 ++++++++++++++++++++++++++++++------- src/ui/header.rs | 1 + src/ui/tables.rs | 71 ++++++++++++++++++++++---------- 7 files changed, 129 insertions(+), 38 deletions(-) diff --git a/assets/config.template.toml b/assets/config.template.toml index 5693843..0b5fd66 100644 --- a/assets/config.template.toml +++ b/assets/config.template.toml @@ -119,7 +119,8 @@ colors = 3 # Rows of the info table, in order. Available rows: # title, separator, blank, os, host, kernel, uptime, packages, shell, # terminal, de, wm, resolution, cpu, cpu_temp, gpu, memory, swap, disk, -# local_ip, battery, load_avg, processes, users, locale, date_time, colors +# local_ip, local_ipv6, battery, load_avg, processes, users, locale, date_time, +# colors items = [ "title", "separator", diff --git a/src/cli.rs b/src/cli.rs index d72d3c4..372443f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -243,6 +243,9 @@ fn write_plain(mut out: impl Write, app: &App) -> io::Result<()> { interface.rx_rate, interface.tx_rate )?; + if let Some(ipv6) = &interface.ipv6 { + writeln!(out, "interface_ipv6: {} {ipv6}", interface.name)?; + } } Ok(()) } @@ -292,7 +295,8 @@ fn write_json(mut out: impl Write, app: &App) -> io::Result<()> { "write_bytes_per_second": device.write_rate, })).collect::>(), "network": d.nets.iter().map(|interface| serde_json::json!({ - "interface": interface.name, "state": interface.state, "ipv4": interface.ipv4, + "interface": interface.name, "state": interface.state, + "ipv4": interface.ipv4, "ipv6": interface.ipv6, "rx_bytes": interface.rx_bytes, "tx_bytes": interface.tx_bytes, "rx_bytes_per_second": interface.rx_rate, "tx_bytes_per_second": interface.tx_rate, diff --git a/src/config/enums.rs b/src/config/enums.rs index 02a835a..ea04e36 100644 --- a/src/config/enums.rs +++ b/src/config/enums.rs @@ -133,6 +133,7 @@ string_enum! { Swap => "swap", Disk => "disk", LocalIp => "local_ip" | "ip", + LocalIpv6 => "local_ipv6" | "ipv6", Battery => "battery", LoadAvg => "load_avg" | "load", Processes => "processes" | "procs", diff --git a/src/sys/mod.rs b/src/sys/mod.rs index 62bbd4b..dbb341a 100644 --- a/src/sys/mod.rs +++ b/src/sys/mod.rs @@ -93,6 +93,7 @@ pub struct Dynamic { pub thread_total: usize, pub users: usize, pub local_ip: String, + pub local_ipv6: String, } impl Dynamic { @@ -159,6 +160,7 @@ impl Sampler { let nets = self.net.sample(elapsed, &cfg.network); let local_ip = net::local_ip(&nets); + let local_ipv6 = net::local_ipv6(&nets); Dynamic { uptime: os::uptime(), @@ -176,6 +178,7 @@ impl Sampler { thread_total: self.procs.last_threads, users: os::logged_in_users(), local_ip, + local_ipv6, } } } diff --git a/src/sys/net.rs b/src/sys/net.rs index 2582e8a..b3f7cd4 100644 --- a/src/sys/net.rs +++ b/src/sys/net.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::ffi::CStr; use std::fs; +use std::net::Ipv6Addr; use super::read_trimmed; use crate::config::model::Network as NetCfg; @@ -23,6 +24,8 @@ pub struct Interface { pub state: String, /// The first IPv4 address bound to the interface. pub ipv4: Option, + /// The first global IPv6 address, or a link-local address as fallback. + pub ipv6: Option, pub is_loopback: bool, } @@ -86,7 +89,7 @@ impl Sampler { pub fn sample(&mut self, elapsed: f64, cfg: &NetCfg) -> Vec { let text = fs::read_to_string("/proc/net/dev").unwrap_or_default(); - let addrs = ipv4_addresses(); + let addrs = ip_addresses(); let mut out = Vec::new(); let mut current = HashMap::new(); @@ -117,7 +120,12 @@ impl Sampler { rx_rate, tx_rate, state, - ipv4: addrs.get(&name).cloned(), + ipv4: addrs + .get(&name) + .and_then(|addresses| addresses.ipv4.clone()), + ipv6: addrs + .get(&name) + .and_then(|addresses| addresses.ipv6.clone()), is_loopback, name, }; @@ -140,8 +148,14 @@ impl Default for Sampler { } } -/// Maps interface name to its first IPv4 address. -fn ipv4_addresses() -> HashMap { +#[derive(Debug, Clone, Default)] +struct Addresses { + ipv4: Option, + ipv6: Option, +} + +/// Maps interface names to their first IPv4 and preferred IPv6 addresses. +fn ip_addresses() -> HashMap { let mut out = HashMap::new(); let mut head: *mut libc::ifaddrs = std::ptr::null_mut(); @@ -161,19 +175,35 @@ fn ipv4_addresses() -> HashMap { if entry.ifa_addr.is_null() || entry.ifa_name.is_null() { continue; } - if (*entry.ifa_addr).sa_family != libc::AF_INET as libc::sa_family_t { - continue; - } - - let addr = &*(entry.ifa_addr as *const libc::sockaddr_in); - let octets = addr.sin_addr.s_addr.to_ne_bytes(); let Ok(name) = CStr::from_ptr(entry.ifa_name).to_str() else { continue; }; - - out.entry(name.to_string()).or_insert_with(|| { - format!("{}.{}.{}.{}", octets[0], octets[1], octets[2], octets[3]) - }); + let addresses = out + .entry(name.to_string()) + .or_insert_with(Addresses::default); + match (*entry.ifa_addr).sa_family as i32 { + libc::AF_INET => { + let addr = &*(entry.ifa_addr as *const libc::sockaddr_in); + let octets = addr.sin_addr.s_addr.to_ne_bytes(); + addresses.ipv4.get_or_insert_with(|| { + format!("{}.{}.{}.{}", octets[0], octets[1], octets[2], octets[3]) + }); + } + libc::AF_INET6 => { + let addr = &*(entry.ifa_addr as *const libc::sockaddr_in6); + let ip = Ipv6Addr::from(addr.sin6_addr.s6_addr); + let replace_link_local = addresses.ipv6.as_ref().is_some_and(|current| { + current + .parse::() + .is_ok_and(|current| current.is_unicast_link_local()) + && !ip.is_unicast_link_local() + }); + if addresses.ipv6.is_none() || replace_link_local { + addresses.ipv6 = Some(ip.to_string()); + } + } + _ => {} + } } } @@ -191,6 +221,14 @@ pub fn local_ip(interfaces: &[Interface]) -> String { .unwrap_or_default() } +pub fn local_ipv6(interfaces: &[Interface]) -> String { + interfaces + .iter() + .filter(|interface| !interface.is_loopback) + .find_map(|interface| interface.ipv6.clone()) + .unwrap_or_default() +} + #[cfg(test)] mod tests { use super::*; @@ -284,6 +322,22 @@ docker0: 100 1 0 0 0 0 0 0 0 0 assert_eq!(local_ip(&[]), ""); } + #[test] + fn local_ipv6_skips_loopback() { + let interfaces = vec![ + Interface { + ipv6: Some("::1".into()), + is_loopback: true, + ..Default::default() + }, + Interface { + ipv6: Some("2001:db8::10".into()), + ..Default::default() + }, + ]; + assert_eq!(local_ipv6(&interfaces), "2001:db8::10"); + } + #[test] fn up_covers_the_unknown_state() { let up = Interface { diff --git a/src/ui/header.rs b/src/ui/header.rs index 6e1730b..0e75342 100644 --- a/src/ui/header.rs +++ b/src/ui/header.rs @@ -124,6 +124,7 @@ fn rows(app: &App) -> Vec> { ), InfoItem::LocalIp => row("Local IP", d.local_ip.clone()), + InfoItem::LocalIpv6 => row("Local IPv6", d.local_ipv6.clone()), InfoItem::Battery => row( "Battery", d.battery.as_ref().map(|b| b.label()).unwrap_or_default(), diff --git a/src/ui/tables.rs b/src/ui/tables.rs index e5f682e..8643040 100644 --- a/src/ui/tables.rs +++ b/src/ui/tables.rs @@ -246,6 +246,8 @@ pub fn network(frame: &mut Frame, area: Rect, app: &App) { } let theme = &app.theme; + let wide = inner.width >= 120; + let medium = inner.width >= 76; let visible = inner.height.saturating_sub(1) as usize; let offset = clamp_scroll(app.scroll, app.dynamic.nets.len(), visible.max(1)); @@ -256,44 +258,69 @@ pub fn network(frame: &mut Frame, area: Rect, app: &App) { .skip(offset) .map(|n| { let state_color = if n.is_up() { theme.good } else { theme.muted }; - Row::new(vec![ + let address = n + .ipv4 + .as_ref() + .or(n.ipv6.as_ref()) + .cloned() + .unwrap_or_else(|| "—".to_string()); + let mut cells = vec![ Cell::from(n.name.clone()).style(theme.value.fg()), Cell::from(n.state.clone()).style(state_color.fg()), - Cell::from(n.ipv4.clone().unwrap_or_else(|| "—".to_string())) - .style(theme.foreground.fg()), + Cell::from(if wide { + n.ipv4.clone().unwrap_or_else(|| "—".to_string()) + } else { + address + }) + .style(theme.foreground.fg()), + ]; + if wide { + cells.push( + Cell::from(n.ipv6.clone().unwrap_or_else(|| "—".to_string())) + .style(theme.foreground.fg()), + ); + } + cells.extend([ Cell::from(human_rate(n.rx_rate)).style(theme.good.fg()), Cell::from(human_rate(n.tx_rate)).style(theme.graph_secondary.fg()), - Cell::from(human_bytes(n.rx_bytes)).style(theme.muted.fg()), - Cell::from(human_bytes(n.tx_bytes)).style(theme.muted.fg()), - ]) + ]); + if medium { + cells.extend([ + Cell::from(human_bytes(n.rx_bytes)).style(theme.muted.fg()), + Cell::from(human_bytes(n.tx_bytes)).style(theme.muted.fg()), + ]); + } + Row::new(cells) }) .collect(); - let header = Row::new(vec![ - "IFACE", - "STATE", - "IPV4", - "↓ RATE", - "↑ RATE", - "↓ TOTAL", - "↑ TOTAL", - ]) - .style( + let mut headings = vec!["IFACE", "STATE", if wide { "IPV4" } else { "ADDRESS" }]; + if wide { + headings.push("IPV6"); + } + headings.extend(["↓ RATE", "↑ RATE"]); + if medium { + headings.extend(["↓ TOTAL", "↑ TOTAL"]); + } + let header = Row::new(headings).style( theme .accent .fg() .add_modifier(ratatui::style::Modifier::BOLD), ); - let widths = [ + let mut widths = vec![ Constraint::Fill(1), Constraint::Length(8), - Constraint::Length(16), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(11), - Constraint::Length(11), + Constraint::Length(if wide { 16 } else { 24 }), ]; + if wide { + widths.push(Constraint::Length(30)); + } + widths.extend([Constraint::Length(12), Constraint::Length(12)]); + if medium { + widths.extend([Constraint::Length(11), Constraint::Length(11)]); + } frame.render_widget( Table::new(rows, widths) From 3bb556146eabca567a4bceda0bb98ac33dd87e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tuna=20K=C4=B1l=C4=B1=C3=A7?= Date: Tue, 15 Sep 2026 14:22:57 +0300 Subject: [PATCH 7/9] feat(cli): add collector diagnostics --- README.md | 1 + src/app.rs | 4 ++++ src/cli.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/README.md b/README.md index 7ac33ff..ab888c3 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ ratfetch --theme gruvbox-dark ratfetch --interval 250 # four samples a second ratfetch --format json # one machine-readable snapshot ratfetch --format plain # clean line-oriented output +ratfetch --diagnose # explain unavailable collectors ratfetch --list-logos | fzf # browse what is bundled ``` diff --git a/src/app.rs b/src/app.rs index 4ef3e9a..259213b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -586,6 +586,10 @@ impl App { .map(|(process, _)| *process) } + pub fn config_sources(&self) -> &[PathBuf] { + &self.config_files + } + fn switch_tab(&mut self, delta: isize) { let tabs = &self.config.general.tabs; if tabs.is_empty() { diff --git a/src/cli.rs b/src/cli.rs index 372443f..9f3eabc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -95,6 +95,10 @@ pub struct Args { /// List every built-in theme and exit. #[arg(long)] pub list_themes: bool, + + /// Explain which collectors and kernel interfaces are available. + #[arg(long)] + pub diagnose: bool, } impl Args { @@ -188,6 +192,10 @@ fn dispatch(args: &Args) -> Result> { } let app = App::new(loaded, options); + if args.diagnose { + write_diagnostics(io::stdout().lock(), &app)?; + return Ok(ExitCode::SUCCESS); + } let format = match args.format { OutputFormat::Auto if !io::stdout().is_terminal() => OutputFormat::Plain, OutputFormat::Auto => OutputFormat::Tui, @@ -312,6 +320,57 @@ fn write_json(mut out: impl Write, app: &App) -> io::Result<()> { writeln!(out) } +fn write_diagnostics(mut out: impl Write, app: &App) -> io::Result<()> { + writeln!(out, "ratfetch {} diagnostics", crate::VERSION)?; + writeln!( + out, + "platform: {} {}", + std::env::consts::OS, + std::env::consts::ARCH + )?; + if app.config_sources().is_empty() { + writeln!(out, "config: built-in defaults")?; + } else { + for path in app.config_sources() { + writeln!(out, "config: {}", path.display())?; + } + } + + for (name, path, optional) in [ + ("cpu", "/proc/stat", false), + ("memory", "/proc/meminfo", false), + ("processes", "/proc", false), + ("filesystems", "/proc/mounts", false), + ("disk io", "/proc/diskstats", false), + ("network", "/proc/net/dev", false), + ("temperatures", "/sys/class/hwmon", true), + ("gpu", "/sys/class/drm", true), + ("battery", "/sys/class/power_supply", true), + ] { + let status = match std::fs::metadata(path) { + Ok(_) => "ok".to_string(), + Err(error) if optional => format!("optional: {error}"), + Err(error) => format!("unavailable: {error}"), + }; + writeln!(out, "{name}: {status} [{path}]")?; + } + + writeln!(out, "cpu samples: {}", app.dynamic.cpu.per_core.len())?; + writeln!(out, "filesystems found: {}", app.dynamic.disks.len())?; + writeln!(out, "block devices found: {}", app.dynamic.disk_io.len())?; + writeln!(out, "interfaces found: {}", app.dynamic.nets.len())?; + writeln!(out, "processes visible: {}", app.dynamic.procs.len())?; + writeln!( + out, + "temperature sensors found: {}", + app.dynamic.temps.len() + )?; + writeln!(out, "gpus found: {}", app.statics.gpus.len())?; + writeln!(out, "gpus with telemetry: {}", app.dynamic.gpus.len())?; + writeln!(out, "battery found: {}", app.dynamic.battery.is_some())?; + Ok(()) +} + /// `--check-config`: report what would be loaded, without starting the UI. fn check(options: &LoadOptions) -> ExitCode { match config::load(options) { From b0a99dd986212d9c9dd101ca260798e260424903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tuna=20K=C4=B1l=C4=B1=C3=A7?= Date: Tue, 15 Sep 2026 17:06:34 +0300 Subject: [PATCH 8/9] feat(cli): generate completions and manpages --- Cargo.lock | 27 ++++++++++++++++++++++++ Cargo.toml | 2 ++ README.md | 2 ++ src/cli.rs | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index a8a33da..1ae7636 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,6 +200,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.4" @@ -218,6 +227,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1207,6 +1226,8 @@ name = "ratfetch" version = "0.1.0" dependencies = [ "clap", + "clap_complete", + "clap_mangen", "crossterm", "dirs", "libc", @@ -1265,6 +1286,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rustc_version" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index d1398e9..2887830 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ include = ["src/**", "assets/**", "build.rs", "README.md", "LICENSE"] [dependencies] clap = { version = "4.6", features = ["derive"] } +clap_complete = "4.6" +clap_mangen = "0.2" crossterm = "0.29" dirs = "6.0" libc = "0.2" diff --git a/README.md b/README.md index ab888c3..37e9094 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ ratfetch --interval 250 # four samples a second ratfetch --format json # one machine-readable snapshot ratfetch --format plain # clean line-oriented output ratfetch --diagnose # explain unavailable collectors +ratfetch --generate-completion fish +ratfetch --generate-man ratfetch.1 ratfetch --list-logos | fzf # browse what is bundled ``` diff --git a/src/cli.rs b/src/cli.rs index 9f3eabc..a7d8bc7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use std::process::ExitCode; use std::time::Instant; -use clap::{Parser, ValueEnum}; +use clap::{CommandFactory, Parser, ValueEnum}; use crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers, }; @@ -32,6 +32,27 @@ pub enum OutputFormat { Json, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum CompletionShell { + Bash, + Zsh, + Fish, + Elvish, + Powershell, +} + +impl From for clap_complete::Shell { + fn from(shell: CompletionShell) -> Self { + match shell { + CompletionShell::Bash => Self::Bash, + CompletionShell::Zsh => Self::Zsh, + CompletionShell::Fish => Self::Fish, + CompletionShell::Elvish => Self::Elvish, + CompletionShell::Powershell => Self::PowerShell, + } + } +} + /// A live, always-updating system fetch for the terminal. #[derive(Debug, Parser)] #[command(name = "ratfetch", version, about, long_about = None)] @@ -99,6 +120,14 @@ pub struct Args { /// Explain which collectors and kernel interfaces are available. #[arg(long)] pub diagnose: bool, + + /// Generate a completion script and exit. + #[arg(long, value_enum, value_name = "SHELL")] + pub generate_completion: Option, + + /// Generate a man page to stdout or an optional path and exit. + #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")] + pub generate_man: Option, } impl Args { @@ -147,6 +176,28 @@ pub fn run() -> ExitCode { fn dispatch(args: &Args) -> Result> { // The informational modes never touch the terminal. + if let Some(shell) = args.generate_completion { + let mut command = Args::command(); + clap_complete::generate( + clap_complete::Shell::from(shell), + &mut command, + "ratfetch", + &mut io::stdout().lock(), + ); + return Ok(ExitCode::SUCCESS); + } + + if let Some(path) = &args.generate_man { + let man = clap_mangen::Man::new(Args::command()); + if path.is_empty() { + man.render(&mut io::stdout().lock())?; + } else { + man.render(&mut std::fs::File::create(path)?)?; + println!("wrote {path}"); + } + return Ok(ExitCode::SUCCESS); + } + if args.list_logos { let mut out = io::stdout().lock(); for name in crate::logo::all_names() { @@ -602,6 +653,14 @@ mod tests { assert_eq!(args.generate_config, None); } + #[test] + fn generators_parse_their_arguments() { + let args = Args::parse_from(["ratfetch", "--generate-completion", "fish"]); + assert_eq!(args.generate_completion, Some(CompletionShell::Fish)); + let args = Args::parse_from(["ratfetch", "--generate-man"]); + assert_eq!(args.generate_man.as_deref(), Some("")); + } + #[test] fn ctrl_c_is_always_an_interrupt() { assert!(is_interrupt(&KeyEvent::new( From 21f8729a169da4c12418e141c716fd3648810ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tuna=20K=C4=B1l=C4=B1=C3=A7?= Date: Tue, 15 Sep 2026 19:31:48 +0300 Subject: [PATCH 9/9] feat(install): enforce verified releases --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 10 +++++++++- assets/config.template.toml | 2 +- install.sh | 18 ++++++++---------- site/index.html | 4 ++-- 6 files changed, 22 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ae7636..9bda807 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1223,7 +1223,7 @@ dependencies = [ [[package]] name = "ratfetch" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index 2887830..1a78714 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ratfetch" -version = "0.1.0" +version = "0.2.0" edition = "2021" # Set by ratatui 0.30, which is the highest MSRV in the dependency tree. rust-version = "1.88" diff --git a/README.md b/README.md index 37e9094..5defef3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ process table, filesystems and interfaces, all refreshing while you watch. ## What it does - **Live, not a snapshot.** Everything re-reads on a configurable interval. -- **526 logos**, the full set vendored from fastfetch, with their original +- **527 logos**, the full set vendored from fastfetch, with their original colour palettes. Auto-detected from `/etc/os-release`, or pick your own. - **Four tabs** — overview, processes, filesystems, interfaces. - **13 built-in themes**, plus per-role colour overrides. The default is @@ -95,9 +95,17 @@ ratfetch --list-logos | fzf # browse what is bundled | `c` | per-core CPU meters | | `space` | freeze / resume | | `r` | reload the config | +| `/` / `ctrl+u` | filter / clear the process list | +| `enter` / `t` | process details / tree view | +| `x` | confirm and send SIGTERM to the selected process | +| `i` / `e` | full system information / error history | All of them are rebindable. +The process table keeps a visible selection and scrollbar. Mouse clicks switch +tabs, and short split panes automatically prioritize information and meters +over decorative art and graphs. + ## Configuration ```sh diff --git a/assets/config.template.toml b/assets/config.template.toml index 0b5fd66..a8526f4 100644 --- a/assets/config.template.toml +++ b/assets/config.template.toml @@ -34,7 +34,7 @@ watch_config = true [logo] # "auto" detects the distro. Otherwise a logo name ("arch", "nixos", "debian"), # "none", or "file:/path/to/your/art.txt". Run `ratfetch --list-logos` to see -# all 526 of them. +# all 527 of them. source = "auto" # Prefer the compact variant when the logo ships one. small = false diff --git a/install.sh b/install.sh index 99a6239..d7a39c4 100755 --- a/install.sh +++ b/install.sh @@ -68,6 +68,7 @@ fi need tar need mktemp +need sha256sum # --- which version ---------------------------------------------------------- @@ -95,16 +96,13 @@ trap 'rm -rf "$tmp"' EXIT INT TERM fetch_to "$base/${name}.tar.gz" "$tmp/${name}.tar.gz" \ || die "no build published for ${target} in ${VERSION}." -if fetch_to "$base/SHA256SUMS" "$tmp/SHA256SUMS" 2>/dev/null; then - expected=$(grep " ${name}.tar.gz\$" "$tmp/SHA256SUMS" | cut -d' ' -f1 || true) - if [ -n "$expected" ] && command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum "$tmp/${name}.tar.gz" | cut -d' ' -f1) - [ "$expected" = "$actual" ] || die "checksum mismatch for ${name}.tar.gz — refusing to install." - ok "checksum verified" - else - say "skipping checksum verification (no entry or no sha256sum)" - fi -fi +fetch_to "$base/SHA256SUMS" "$tmp/SHA256SUMS" 2>/dev/null \ + || die "could not download SHA256SUMS — refusing an unverified install." +expected=$(grep " ${name}.tar.gz\$" "$tmp/SHA256SUMS" | cut -d' ' -f1 || true) +[ -n "$expected" ] || die "SHA256SUMS has no entry for ${name}.tar.gz." +actual=$(sha256sum "$tmp/${name}.tar.gz" | cut -d' ' -f1) +[ "$expected" = "$actual" ] || die "checksum mismatch for ${name}.tar.gz — refusing to install." +ok "checksum verified" tar -xzf "$tmp/${name}.tar.gz" -C "$tmp" [ -x "$tmp/$name/ratfetch" ] || die "the archive did not contain a ratfetch binary." diff --git a/site/index.html b/site/index.html index f8a5626..9a91e4c 100644 --- a/site/index.html +++ b/site/index.html @@ -80,7 +80,7 @@

ratfetch

What it does

  • Live, not a snapshot. Everything re-reads on a configurable interval.
  • -
  • 526 logos, vendored from fastfetch with their original colour palettes.
  • +
  • 527 logos, vendored from fastfetch with their original colour palettes.
  • Four tabs — overview, processes, filesystems, interfaces.
  • 13 themes, plus per-role colour overrides.
  • No sysinfo crate. /proc and /sys are read directly.
  • @@ -91,7 +91,7 @@

    What it does

    Other ways to install

    # a specific version, or a different directory
    -RATFETCH_VERSION=v0.1.0 sh -c "$(curl -fsSL https://tuna4ll.github.io/ratfetch/install.sh)"
    +RATFETCH_VERSION=v0.2.0 sh -c "$(curl -fsSL https://tuna4ll.github.io/ratfetch/install.sh)"
     RATFETCH_INSTALL_DIR=~/bin sh -c "$(curl -fsSL https://tuna4ll.github.io/ratfetch/install.sh)"
     
     # manual setup, from source — needs Rust 1.88+