6fa762d9e1
Mechanical fixes: collapsible-if, is_multiple_of, sort_by_key, needless borrows, useless format!, manual checked division, push-after-creation, and other style lints. Zero behavioral changes. Remaining 16 warnings are pre-existing intentional patterns (identical if-blocks from match arms, &mut Vec params).
2559 lines
93 KiB
Rust
2559 lines
93 KiB
Rust
//! TUI rendering: header, per-CPU table, controls panel, help overlay,
|
|
//! and the snapshot dump used by `--once` and the `c` key.
|
|
//!
|
|
//! Layout (3 vertical panels, no scrolling on typical laptops):
|
|
//!
|
|
//! ┌─ redbear-power ──────┐ ┌─ Controls ──┐
|
|
//! │ Vendor / Cores / │ │ [g] cycle │
|
|
//! │ Governor / Pkg / │ │ [p/P] +/- │
|
|
//! │ MSR / Daemons / │ │ ... │
|
|
//! └─────────────────────┘ └────────────┘
|
|
//! ┌─ Per-CPU ───────────────┐
|
|
//! │ CPU Freq PkgW Temp │
|
|
//! │ 0 2400 15.0 72▏▌·· │
|
|
//! │ 1 2400 15.0 70▏▎·· │
|
|
//! └────────────────────────┘
|
|
|
|
use std::io;
|
|
|
|
use ratatui::backend::TestBackend;
|
|
use ratatui::layout::{Constraint, Layout};
|
|
#[allow(unused_imports)]
|
|
use ratatui::style::{Style, Styled, Stylize};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs, Wrap};
|
|
use ratatui::{Frame, Terminal};
|
|
|
|
use crate::app::{App, CpuRow, SPARK_WIDTH, TabId, ThrottleMode};
|
|
use crate::cpuid;
|
|
use crate::graph::BrailleGraph;
|
|
use crate::theme::{self, Theme};
|
|
|
|
pub const HEADER_LINES: u16 = 7;
|
|
pub const KEYBAR_LINES: u16 = 1;
|
|
pub const TAB_BAR_LINES: u16 = 1;
|
|
pub const GRAPH_HEIGHT: u16 = 6;
|
|
|
|
/// Map a 0..=100 value to the matching Unicode sparkline character.
|
|
/// Matches ratatui's `NINE_LEVELS` set so the visual is consistent
|
|
/// if a real Sparkline widget is ever substituted in.
|
|
pub fn padded_to_sparkline(values: &[u8]) -> String {
|
|
const BARS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
let mut out = String::with_capacity(values.len());
|
|
for &v in values {
|
|
let idx = ((v as usize).min(100) * 8 / 100).min(8);
|
|
out.push(BARS[idx]);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Map a 0..=255 value to a sparkline char. Used for IO rate
|
|
/// sparklines (already normalized against the per-PID max).
|
|
pub fn io_rate_sparkline(values: &[u8]) -> String {
|
|
const BARS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
let mut out = String::with_capacity(values.len());
|
|
for &v in values {
|
|
let idx = ((v as usize).min(255) * 8 / 255).min(8);
|
|
out.push(BARS[idx]);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Render a short sparkline (CPU% or RSS history) at a fixed
|
|
/// width. Takes the LAST `width` samples of the history; if the
|
|
/// history is shorter, pads with leading spaces. Returns `width`
|
|
/// chars total. Used for the compact 6-char CPU% and RSS
|
|
/// sparklines next to the 12-char IO-RATE sparkline.
|
|
fn sparkline_short(
|
|
history: &std::collections::BTreeMap<u32, std::collections::VecDeque<u8>>,
|
|
pid: &u32,
|
|
width: usize,
|
|
) -> String {
|
|
history
|
|
.get(pid)
|
|
.map(|hist| {
|
|
let bytes: Vec<u8> = hist.iter().copied().collect();
|
|
let start = bytes.len().saturating_sub(width);
|
|
let slice = &bytes[start..];
|
|
let mut out = io_rate_sparkline(slice);
|
|
while out.chars().count() < width {
|
|
out.insert(0, ' ');
|
|
}
|
|
out
|
|
})
|
|
.unwrap_or_else(|| " ".repeat(width))
|
|
}
|
|
|
|
/// Build a fixed-width horizontal bar that visualizes a 0..=100
|
|
/// value. The bar grows from the left, with the rightmost cells
|
|
/// remaining as light filler so the user can read the proportion
|
|
/// at a glance. Used in the per-CPU Temp column.
|
|
pub fn horizontal_bar(pct: u8, width: usize) -> String {
|
|
const FILLED: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
let p = pct.min(100) as usize;
|
|
let total_eighths = p * width * 8 / 100;
|
|
let full_blocks = total_eighths / 8;
|
|
let rem = total_eighths % 8;
|
|
let mut out = String::with_capacity(width);
|
|
for _ in 0..full_blocks.min(width) {
|
|
out.push('█');
|
|
}
|
|
if full_blocks < width {
|
|
if rem > 0 {
|
|
out.push(FILLED[rem]);
|
|
}
|
|
for _ in (full_blocks + if rem > 0 { 1 } else { 0 })..width {
|
|
out.push('·');
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Border style for a panel based on whether it has keyboard focus.
|
|
pub fn panel_border<'a>(focused: bool, title: &'a str, theme: &Theme) -> Block<'a> {
|
|
let border_style = if focused {
|
|
theme.border_focused
|
|
} else {
|
|
theme.border_dim
|
|
};
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_style(border_style)
|
|
.title(title)
|
|
}
|
|
|
|
/// Build a pulsing full-width PROCHOT alert bar, or `None` if no CPU
|
|
/// has PROCHOT asserted.
|
|
pub fn render_prochot_alert(app: &App, frame: &Frame) -> Option<Paragraph<'static>> {
|
|
if !app.cpus.iter().any(|c| c.prochot) {
|
|
return None;
|
|
}
|
|
let phase = (frame.count() / 2) % 2;
|
|
let (bar_char, indicator) = if phase == 0 {
|
|
('█', ' ')
|
|
} else {
|
|
(' ', '▌')
|
|
};
|
|
let width = frame.area().width as usize;
|
|
let line = format!(
|
|
"{}{}{}{}",
|
|
bar_char,
|
|
indicator,
|
|
bar_char.to_string().repeat(width.saturating_sub(2)),
|
|
bar_char
|
|
);
|
|
Some(Paragraph::new(line).style(theme::PROCHOT_PULSE))
|
|
}
|
|
|
|
/// tlc-style toast notification. Floating box at bottom-right.
|
|
pub fn render_toast<'a>(app: &'a App) -> Option<Paragraph<'a>> {
|
|
use crate::theme;
|
|
let msg = app.active_toast()?;
|
|
Some(
|
|
Paragraph::new(Line::from(vec![
|
|
ratatui::text::Span::styled(" \u{25CF} ", theme::STATUS_OK),
|
|
ratatui::text::Span::styled(msg, theme::VALUE),
|
|
]))
|
|
.style(theme::VALUE)
|
|
.block(Block::default().borders(Borders::ALL).title(" Notice "))
|
|
.alignment(ratatui::layout::Alignment::Left),
|
|
)
|
|
}
|
|
|
|
pub fn render_header<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|
let pkg_temp = app
|
|
.cpus
|
|
.iter()
|
|
.filter_map(|c| c.temp_c)
|
|
.max()
|
|
.map(|t| format!("{t}°C"))
|
|
.unwrap_or_else(|| "n/a".into());
|
|
let lines = vec![
|
|
Line::from(vec![
|
|
"Vendor: ".set_style(theme::LABEL),
|
|
format!("{} ", app.cpu_vendor).into(),
|
|
"Model: ".set_style(theme::LABEL),
|
|
app.cpu_model.as_str().into(),
|
|
]),
|
|
Line::from(vec![
|
|
"Cores: ".set_style(theme::LABEL),
|
|
format!("{} ", app.cpus.len()).into(),
|
|
"Governor: ".set_style(theme::LABEL),
|
|
app.cpufreq
|
|
.active
|
|
.as_str()
|
|
.set_style(app.theme.header_governor),
|
|
" ".into(),
|
|
"Throttle: ".set_style(theme::LABEL),
|
|
match app.throttle {
|
|
ThrottleMode::Auto => "AUTO".set_style(theme::HEADER_THROTTLE_AUTO),
|
|
ThrottleMode::User => "USER".set_style(theme::HEADER_THROTTLE_USER),
|
|
ThrottleMode::ForcedMin => "FORCED MIN".set_style(theme::HEADER_THROTTLE_FORCED),
|
|
},
|
|
]),
|
|
Line::from(vec![
|
|
"Pkg: ".set_style(theme::LABEL),
|
|
pkg_temp.set_style(Style::default().fg(theme::temp_color(
|
|
app.cpus.iter().filter_map(|c| c.temp_c).max(),
|
|
))),
|
|
" ".into(),
|
|
"PkgW: ".set_style(theme::LABEL),
|
|
app.pkg_power_w
|
|
.map(|w| format!("{w:.1} W"))
|
|
.unwrap_or_else(|| app.rapl_status.clone())
|
|
.set_style(if app.pkg_power_w.is_some() {
|
|
theme::VALUE
|
|
} else {
|
|
theme::VALUE_OFF
|
|
}),
|
|
" ".into(),
|
|
"P-state source: ".set_style(theme::LABEL),
|
|
app.pss_source.as_str().into(),
|
|
]),
|
|
Line::from(vec![
|
|
"SIMD: ".set_style(theme::LABEL),
|
|
app.simd.as_str().set_style(theme::VALUE),
|
|
" ".into(),
|
|
"Cache: ".set_style(theme::LABEL),
|
|
app.cache_summary.as_str().set_style(theme::VALUE),
|
|
]),
|
|
Line::from(vec![
|
|
"Sources: ".set_style(theme::LABEL),
|
|
"MSR=".set_style(theme::VALUE_OFF),
|
|
if app.msr_available {
|
|
"ok".set_style(theme::VALUE_OK)
|
|
} else {
|
|
"no".set_style(theme::STATUS_ERR)
|
|
},
|
|
" PSS=".set_style(theme::VALUE_OFF),
|
|
if app.pss_available {
|
|
"ok".set_style(theme::VALUE_OK)
|
|
} else {
|
|
"no".set_style(theme::STATUS_ERR)
|
|
},
|
|
" load=".set_style(theme::VALUE_OFF),
|
|
if app.load_available {
|
|
"ok".set_style(theme::VALUE_OK)
|
|
} else {
|
|
"no".set_style(theme::STATUS_ERR)
|
|
},
|
|
" gov=".set_style(theme::VALUE_OFF),
|
|
if app.governor_available {
|
|
"ok".set_style(theme::VALUE_OK)
|
|
} else {
|
|
"no".set_style(theme::STATUS_ERR)
|
|
},
|
|
" hwmon=".set_style(theme::VALUE_OFF),
|
|
if app.hwmon_available {
|
|
"ok".set_style(theme::VALUE_OK)
|
|
} else {
|
|
"no".set_style(theme::STATUS_ERR)
|
|
},
|
|
" dmi=".set_style(theme::VALUE_OFF),
|
|
if !app.dmi.is_empty() {
|
|
"ok".set_style(theme::VALUE_OK)
|
|
} else {
|
|
"no".set_style(theme::STATUS_ERR)
|
|
},
|
|
]),
|
|
Line::from(vec![
|
|
"Hybrid: ".set_style(theme::LABEL),
|
|
if app.hybrid_summary.is_empty() {
|
|
"non-hybrid".set_style(theme::VALUE_OFF)
|
|
} else {
|
|
app.hybrid_summary.as_str().set_style(theme::VALUE_HOT)
|
|
},
|
|
]),
|
|
Line::from(vec![
|
|
"Daemons: ".set_style(theme::LABEL),
|
|
"cpufreqd=".set_style(theme::VALUE_OFF),
|
|
if app.cpufreqd_available {
|
|
"up".set_style(theme::VALUE_OK)
|
|
} else {
|
|
"DOWN".set_style(theme::STATUS_ERR)
|
|
},
|
|
" ".into(),
|
|
"thermald=".set_style(theme::VALUE_OFF),
|
|
if app.thermald_available {
|
|
"up".set_style(theme::VALUE_OK)
|
|
} else {
|
|
"DOWN".set_style(theme::STATUS_ERR)
|
|
},
|
|
]),
|
|
];
|
|
Paragraph::new(lines)
|
|
.block(panel_border(focused, " redbear-power ", &app.theme))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
/// Format a kibibyte value as human-readable ("1.5 GiB", "512 MiB",
|
|
/// "16 KiB"). Mirrors cpu-x's `PrefixUnit` helper but inline.
|
|
pub fn format_kib(kib: u64) -> String {
|
|
if kib >= 1024 * 1024 {
|
|
format!("{:.1} GiB", kib as f64 / (1024.0 * 1024.0))
|
|
} else if kib >= 1024 {
|
|
format!("{:.1} MiB", kib as f64 / 1024.0)
|
|
} else {
|
|
format!("{} KiB", kib)
|
|
}
|
|
}
|
|
|
|
/// Build a memory-bar line: "Label [bar] XX% value/total".
|
|
/// The bar uses Unicode block characters (`█` filled, `░` empty).
|
|
/// Bar width is fixed at 20 cells; the line stays under 80 columns
|
|
/// for typical terminal widths.
|
|
fn mem_bar_line<'a>(
|
|
label: &'a str,
|
|
percent: f64,
|
|
value_kib: u64,
|
|
total_kib: u64,
|
|
color: Style,
|
|
) -> Line<'a> {
|
|
let bar_width: usize = 20;
|
|
let filled = ((percent.clamp(0.0, 100.0) / 100.0) * bar_width as f64) as usize;
|
|
let mut bar = String::with_capacity(bar_width * 3);
|
|
for _ in 0..filled {
|
|
bar.push('\u{2588}'); // full block
|
|
}
|
|
for _ in filled..bar_width {
|
|
bar.push('\u{2591}'); // light shade
|
|
}
|
|
// Threshold gradient: green <50%, yellow 50-75%, red >75%.
|
|
let threshold_color = if percent < 50.0 {
|
|
theme::VALUE_OK
|
|
} else if percent < 75.0 {
|
|
theme::STATUS_WARN
|
|
} else {
|
|
theme::VALUE_HOT
|
|
};
|
|
// Size-based tint: apply Modifiers (BOLD at 50%, BOLD|REVERSED at 90%+)
|
|
// for visual emphasis at high pressure — tlc size_tint pattern.
|
|
let mut percent_style = theme::VALUE;
|
|
if percent >= 90.0 {
|
|
percent_style = percent_style.add_modifier(ratatui::style::Modifier::BOLD);
|
|
}
|
|
if percent >= 95.0 {
|
|
percent_style = percent_style.add_modifier(ratatui::style::Modifier::REVERSED);
|
|
}
|
|
Line::from(vec![
|
|
label.set_style(theme::LABEL),
|
|
format!("[{}] ", bar).set_style(if filled > 0 { threshold_color } else { color }),
|
|
format!("{:5.1}% ", percent).set_style(percent_style),
|
|
format!(
|
|
"{} / {}",
|
|
crate::render::format_kib(value_kib),
|
|
crate::render::format_kib(total_kib)
|
|
)
|
|
.set_style(theme::VALUE_OFF),
|
|
])
|
|
}
|
|
|
|
/// Render the multi-view tab bar (Per-CPU / System / Info / Motherboard)
|
|
/// with the active tab highlighted. Hotkeys `1`/`2`/`3`/`4` switch
|
|
/// directly; `T` cycles through them in order.
|
|
pub fn render_tab_bar<'a>(app: &'a App) -> Tabs<'a> {
|
|
let proc_count = app.processes.processes.len();
|
|
let titles: Vec<Line<'a>> = [
|
|
TabId::PerCpu,
|
|
TabId::System,
|
|
TabId::Info,
|
|
TabId::Motherboard,
|
|
TabId::Battery,
|
|
TabId::Sensors,
|
|
TabId::Network,
|
|
TabId::Storage,
|
|
TabId::Process,
|
|
]
|
|
.iter()
|
|
.map(|t| {
|
|
let name = t.name();
|
|
if matches!(t, TabId::Process) && proc_count > 0 {
|
|
Line::from(format!("{} ({})", name, proc_count))
|
|
} else {
|
|
Line::from(name)
|
|
}
|
|
})
|
|
.collect();
|
|
let selected = match app.current_tab {
|
|
TabId::PerCpu => 0,
|
|
TabId::System => 1,
|
|
TabId::Info => 2,
|
|
TabId::Motherboard => 3,
|
|
TabId::Battery => 4,
|
|
TabId::Sensors => 5,
|
|
TabId::Network => 6,
|
|
TabId::Storage => 7,
|
|
TabId::Process => 8,
|
|
};
|
|
Tabs::new(titles)
|
|
.select(selected)
|
|
.style(theme::BORDER_DIM)
|
|
.highlight_style(theme::LABEL_BOLD)
|
|
.divider(" │ ")
|
|
}
|
|
|
|
/// Render the System tab (memory/uptime/etc). Uses `/proc/meminfo` on
|
|
/// Linux and `/scheme/sys/mem` on Redox if present.
|
|
pub fn render_system_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|
let mut lines: Vec<Line<'a>> = Vec::new();
|
|
let n = app.cpus.len();
|
|
let avg_freq: f64 = if n > 0 {
|
|
app.cpus.iter().map(|c| c.freq_khz as f64).sum::<f64>() / n as f64 / 1000.0
|
|
} else {
|
|
0.0
|
|
};
|
|
let max_temp = app
|
|
.cpus
|
|
.iter()
|
|
.filter_map(|c| c.temp_c)
|
|
.max()
|
|
.map(|t| format!("{t}°C"))
|
|
.unwrap_or_else(|| "n/a".to_string());
|
|
let total_pkgw: f64 = app
|
|
.cpus
|
|
.iter()
|
|
.filter_map(|c| c.current_power_mw)
|
|
.map(|w| w as f64 / 1000.0)
|
|
.sum();
|
|
lines.push(Line::from(vec![
|
|
"Cores: ".set_style(theme::LABEL),
|
|
format!("{n}").set_style(theme::VALUE),
|
|
" AvgFreq: ".set_style(theme::LABEL),
|
|
format!("{avg_freq:.0} MHz").set_style(theme::VALUE),
|
|
" MaxTemp: ".set_style(theme::LABEL),
|
|
max_temp.set_style(theme::VALUE),
|
|
" TotalPkg: ".set_style(theme::LABEL),
|
|
format!("{total_pkgw:.1} W").set_style(theme::VALUE),
|
|
" Load: ".set_style(theme::LABEL),
|
|
match app.load_avg {
|
|
Some((a, b, c)) => format!("{a:.2} {b:.2} {c:.2}").set_style(theme::VALUE),
|
|
None => "n/a".set_style(theme::VALUE_OFF),
|
|
},
|
|
]));
|
|
let any_prochot = app.cpus.iter().any(|c| c.prochot);
|
|
let any_critical = app.cpus.iter().any(|c| c.critical);
|
|
let any_pl = app.cpus.iter().any(|c| c.power_limit);
|
|
lines.push(Line::from(vec![
|
|
"Aggregate flags: ".set_style(theme::LABEL),
|
|
if any_prochot {
|
|
"PROCHOT ".set_style(theme::PROCHOT_FLAG)
|
|
} else {
|
|
"PROCHOT ".set_style(theme::VALUE_OFF)
|
|
},
|
|
if any_critical {
|
|
"CRIT ".set_style(theme::VALUE_HOT)
|
|
} else {
|
|
"CRIT ".set_style(theme::VALUE_OFF)
|
|
},
|
|
if any_pl {
|
|
"PL ".set_style(theme::POWER_LIMIT_FLAG)
|
|
} else {
|
|
"PL ".set_style(theme::VALUE_OFF)
|
|
},
|
|
]));
|
|
|
|
{
|
|
let s = &app.sched_stats;
|
|
let mut parts: Vec<ratatui::text::Span> = Vec::new();
|
|
parts.push("Sched: ".set_style(theme::LABEL));
|
|
let mut wrote = false;
|
|
if let Some(v) = s.context_switches {
|
|
parts.push(format!("switches={v} ").set_style(theme::VALUE));
|
|
wrote = true;
|
|
}
|
|
if let Some(v) = s.contexts_created {
|
|
parts.push(format!("created={v} ").set_style(theme::VALUE));
|
|
wrote = true;
|
|
}
|
|
if let Some(v) = s.contexts_running {
|
|
parts.push(format!("running={v} ").set_style(theme::VALUE));
|
|
wrote = true;
|
|
}
|
|
if let Some(v) = s.contexts_blocked {
|
|
parts.push(format!("blocked={v} ").set_style(theme::VALUE));
|
|
wrote = true;
|
|
}
|
|
if let Some(v) = s.total_irqs {
|
|
parts.push(format!("IRQs={v} ").set_style(theme::VALUE));
|
|
wrote = true;
|
|
}
|
|
if !s.per_cpu_steals.is_empty() {
|
|
let total_steals: u64 = s.per_cpu_steals.iter().sum();
|
|
parts.push(format!("steals={total_steals} ").set_style(theme::VALUE));
|
|
wrote = true;
|
|
}
|
|
if !s.per_cpu_queue_depth.is_empty() {
|
|
let total_qd: u64 = s.per_cpu_queue_depth.iter().sum();
|
|
let avg_qd = total_qd / s.per_cpu_queue_depth.len() as u64;
|
|
parts.push(format!("avg_qd={avg_qd}").set_style(theme::VALUE));
|
|
wrote = true;
|
|
}
|
|
if wrote {
|
|
lines.push(Line::from(parts));
|
|
}
|
|
}
|
|
|
|
if app.pkg_power_w.is_some()
|
|
|| app.pp0_power_w.is_some()
|
|
|| app.pp1_power_w.is_some()
|
|
|| app.dram_power_w.is_some()
|
|
{
|
|
let mut parts: Vec<ratatui::text::Span> = Vec::new();
|
|
parts.push("Power: ".set_style(theme::LABEL));
|
|
if let Some(w) = app.pkg_power_w {
|
|
parts.push(format!("Pkg {w:.1}W ").set_style(theme::VALUE));
|
|
}
|
|
if let Some(w) = app.pp0_power_w {
|
|
parts.push(format!("Core {w:.1}W ").set_style(theme::VALUE));
|
|
}
|
|
if let Some(w) = app.pp1_power_w {
|
|
parts.push(format!("Uncore {w:.1}W ").set_style(theme::VALUE));
|
|
}
|
|
if let Some(w) = app.dram_power_w {
|
|
parts.push(format!("DRAM {w:.1}W").set_style(theme::VALUE));
|
|
}
|
|
lines.push(Line::from(parts));
|
|
}
|
|
|
|
// OS identity (matches cpu-x System tab)
|
|
if app.os_info.available {
|
|
lines.push(Line::from(vec![
|
|
"OS: ".set_style(theme::LABEL),
|
|
if app.os_info.name.is_empty() {
|
|
"(unknown)".set_style(theme::VALUE_OFF)
|
|
} else {
|
|
app.os_info.name.as_str().set_style(theme::VALUE)
|
|
},
|
|
" Kernel: ".set_style(theme::LABEL),
|
|
if app.os_info.kernel.is_empty() {
|
|
"(unknown)".set_style(theme::VALUE_OFF)
|
|
} else {
|
|
app.os_info.kernel.as_str().set_style(theme::VALUE)
|
|
},
|
|
" Host: ".set_style(theme::LABEL),
|
|
if app.os_info.hostname.is_empty() {
|
|
"(unknown)".set_style(theme::VALUE_OFF)
|
|
} else {
|
|
app.os_info.hostname.as_str().set_style(theme::VALUE)
|
|
},
|
|
" Up: ".set_style(theme::LABEL),
|
|
crate::meminfo::format_uptime(app.os_info.uptime_secs).set_style(theme::VALUE_HOT),
|
|
]));
|
|
}
|
|
|
|
// Memory panel (matches cpu-x System tab memory bars). Each line
|
|
// shows label, value, and a horizontal bar.
|
|
if app.meminfo.available {
|
|
let mi = &app.meminfo;
|
|
lines.push(Line::from(vec![
|
|
"Mem: ".set_style(theme::LABEL_BOLD),
|
|
format!(
|
|
"{} used / {} total",
|
|
crate::render::format_kib(mi.used_kib),
|
|
crate::render::format_kib(mi.total_kib)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
]));
|
|
lines.push(mem_bar_line(
|
|
"Used: ",
|
|
mi.percent_used(),
|
|
mi.used_kib,
|
|
mi.total_kib,
|
|
theme::VALUE_HOT,
|
|
));
|
|
lines.push(mem_bar_line(
|
|
"Buffers: ",
|
|
mi.percent_buffers(),
|
|
mi.buffers_kib,
|
|
mi.total_kib,
|
|
theme::VALUE,
|
|
));
|
|
lines.push(mem_bar_line(
|
|
"Cached: ",
|
|
mi.percent_cached(),
|
|
mi.cached_kib,
|
|
mi.total_kib,
|
|
theme::VALUE,
|
|
));
|
|
lines.push(mem_bar_line(
|
|
"Free: ",
|
|
mi.percent_free(),
|
|
mi.free_kib,
|
|
mi.total_kib,
|
|
theme::VALUE_OK,
|
|
));
|
|
if mi.swap_total_kib > 0 {
|
|
lines.push(mem_bar_line(
|
|
"Swap: ",
|
|
mi.percent_swap(),
|
|
mi.swap_used_kib,
|
|
mi.swap_total_kib,
|
|
theme::STATUS_WARN,
|
|
));
|
|
}
|
|
}
|
|
|
|
let cs = &app.collector_stats;
|
|
if cs.thread_count > 1 {
|
|
lines.push(Line::from(vec![
|
|
"Collector: ".set_style(theme::LABEL),
|
|
format!("{} threads", cs.thread_count).set_style(theme::VALUE),
|
|
" pinned: ".set_style(theme::LABEL),
|
|
format!("{}/{}", cs.pinned_count, cs.barrier_size).set_style(theme::VALUE),
|
|
" barrier: ".set_style(theme::LABEL),
|
|
format!("{}", cs.barrier_size).set_style(theme::VALUE),
|
|
" last: ".set_style(theme::LABEL),
|
|
format!("{:.2} ms", cs.elapsed_us as f64 / 1000.0).set_style(theme::VALUE),
|
|
]));
|
|
} else {
|
|
lines.push(Line::from(vec![
|
|
"Collector: ".set_style(theme::LABEL),
|
|
"single-threaded".set_style(theme::VALUE_OFF),
|
|
]));
|
|
}
|
|
|
|
lines.push(Line::from(vec![if app.bench_line.is_empty() {
|
|
"(idle)".set_style(theme::VALUE_OFF)
|
|
} else {
|
|
app.bench_line.as_str().set_style(theme::VALUE)
|
|
}]));
|
|
Paragraph::new(lines)
|
|
.block(panel_border(focused, " System ", &app.theme))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
/// Render the Info tab (static CPU identification details).
|
|
pub fn render_info_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|
let family = app.cpuid_info.family;
|
|
let model = app.cpuid_info.model_id;
|
|
let stepping = app.cpuid_info.stepping;
|
|
let caps = &app.cpuid_info.features;
|
|
let mut flags = Vec::new();
|
|
for (bit, label) in [
|
|
(caps.mmx, "MMX"),
|
|
(caps.sse, "SSE"),
|
|
(caps.sse2, "SSE2"),
|
|
(caps.sse3, "SSE3"),
|
|
(caps.ssse3, "SSSE3"),
|
|
(caps.sse4_1, "SSE4.1"),
|
|
(caps.sse4_2, "SSE4.2"),
|
|
(caps.sse4a, "SSE4A"),
|
|
(caps.avx, "AVX"),
|
|
(caps.avx2, "AVX2"),
|
|
(caps.avx512f, "AVX-512F"),
|
|
(caps.aes, "AES"),
|
|
(caps.sha_ni, "SHA-NI"),
|
|
(caps.pclmulqdq, "PCLMUL"),
|
|
(caps.fma3, "FMA3"),
|
|
(caps.vmx, "VMX"),
|
|
(caps.svm, "SVM"),
|
|
(caps.hypervisor, "HYP"),
|
|
(caps.popcnt, "POPCNT"),
|
|
] {
|
|
if bit {
|
|
flags.push(label);
|
|
}
|
|
}
|
|
let flag_str = flags.join(" ");
|
|
let caches = &app.cpuid_info.caches;
|
|
let mut cache_lines: Vec<Line<'a>> = Vec::new();
|
|
if let Some(c) = caches.l1d {
|
|
cache_lines.push(Line::from(
|
|
format!(
|
|
" L1d: {} KB, {}-way, {}B line",
|
|
c.size_kb, c.associativity, c.line_bytes
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
}
|
|
if let Some(c) = caches.l1i {
|
|
cache_lines.push(Line::from(
|
|
format!(
|
|
" L1i: {} KB, {}-way, {}B line",
|
|
c.size_kb, c.associativity, c.line_bytes
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
}
|
|
if let Some(c) = caches.l2 {
|
|
cache_lines.push(Line::from(
|
|
format!(
|
|
" L2: {} KB, {}-way, {}B line",
|
|
c.size_kb, c.associativity, c.line_bytes
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
}
|
|
if let Some(c) = caches.l3 {
|
|
let size = if c.size_kb >= 1024 {
|
|
format!("{} MB", c.size_kb / 1024)
|
|
} else {
|
|
format!("{} KB", c.size_kb)
|
|
};
|
|
cache_lines.push(Line::from(
|
|
format!(
|
|
" L3: {size}, {}-way, {}B line",
|
|
c.associativity, c.line_bytes
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
}
|
|
let mut lines = vec![
|
|
Line::from(vec![
|
|
"Vendor: ".set_style(theme::LABEL),
|
|
app.cpu_vendor.as_str().set_style(theme::VALUE),
|
|
" Model: ".set_style(theme::LABEL),
|
|
app.cpu_model.as_str().set_style(theme::VALUE),
|
|
]),
|
|
Line::from(
|
|
format!("Family {family:#x}, Model {model:#x}, Stepping {stepping:#x}")
|
|
.set_style(theme::VALUE),
|
|
),
|
|
Line::from(format!("Flags: {flag_str}").set_style(theme::VALUE)),
|
|
];
|
|
if !cache_lines.is_empty() {
|
|
lines.push(Line::from("Caches:".set_style(theme::LABEL_BOLD)));
|
|
lines.extend(cache_lines);
|
|
}
|
|
if app.cpuid_info.hybrid.is_hybrid {
|
|
let summary = if app.hybrid_summary.is_empty() {
|
|
"(hybrid topology detected)"
|
|
} else {
|
|
app.hybrid_summary.as_str()
|
|
};
|
|
lines.push(Line::from(
|
|
format!("Hybrid: {summary}").set_style(theme::VALUE_HOT),
|
|
));
|
|
}
|
|
Paragraph::new(lines)
|
|
.block(panel_border(focused, " Info ", &app.theme))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
pub fn render_motherboard_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|
let dmi = &app.dmi;
|
|
let empty_msg = if dmi.is_empty() {
|
|
"(no DMI data — /sys/class/dmi/id not readable)"
|
|
} else {
|
|
""
|
|
};
|
|
let mut lines: Vec<Line<'a>> = Vec::new();
|
|
lines.push(Line::from("System".set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(vec![
|
|
" Manufacturer: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.sys_vendor).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Product: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.product_name).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Family: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.product_family).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Version: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.product_version).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Serial: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.product_serial).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" UUID: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.product_uuid).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("Board".set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(vec![
|
|
" Manufacturer: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.board_vendor).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Name: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.board_name).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Version: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.board_version).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Serial: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.board_serial).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Asset Tag: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.board_asset_tag).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("BIOS".set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(vec![
|
|
" Vendor: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.bios_vendor).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Version: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.bios_version).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Date: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.bios_date).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Release: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.bios_release).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("Chassis".set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(vec![
|
|
" Vendor: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.chassis_vendor).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Type: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.chassis_type).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Version: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.chassis_version).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Asset Tag: ".set_style(theme::LABEL),
|
|
crate::dmi::DmiInfo::display(&dmi.chassis_asset_tag).set_style(theme::VALUE),
|
|
]));
|
|
if !empty_msg.is_empty() {
|
|
lines.push(Line::from(empty_msg.set_style(theme::VALUE_WARM)));
|
|
}
|
|
Paragraph::new(lines)
|
|
.block(panel_border(focused, " Motherboard ", &app.theme))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
pub fn render_battery_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|
let bat = &app.battery;
|
|
if !bat.available {
|
|
return Paragraph::new(Line::from(
|
|
"(no battery detected — /sys/class/power_supply/BAT* not present)"
|
|
.set_style(theme::VALUE_WARM),
|
|
))
|
|
.block(panel_border(focused, " Battery ", &app.theme))
|
|
.wrap(Wrap { trim: true });
|
|
}
|
|
let mut lines: Vec<Line<'a>> = Vec::new();
|
|
lines.push(Line::from("Identity".set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(vec![
|
|
" Manufacturer: ".set_style(theme::LABEL),
|
|
crate::battery::BatteryInfo::display(&bat.manufacturer).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Model: ".set_style(theme::LABEL),
|
|
crate::battery::BatteryInfo::display(&bat.model_name).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Technology: ".set_style(theme::LABEL),
|
|
crate::battery::BatteryInfo::display(&bat.technology).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Serial: ".set_style(theme::LABEL),
|
|
crate::battery::BatteryInfo::display(&bat.serial_number).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Cycles: ".set_style(theme::LABEL),
|
|
crate::battery::BatteryInfo::display_u32(&bat.cycle_count).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("State".set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(vec![
|
|
" Status: ".set_style(theme::LABEL),
|
|
crate::battery::BatteryInfo::display(&bat.status).set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Capacity: ".set_style(theme::LABEL),
|
|
crate::battery::BatteryInfo::display_u32(&bat.capacity_percent).set_style(theme::VALUE),
|
|
"%".set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Energy: ".set_style(theme::LABEL),
|
|
crate::battery::BatteryInfo::display_f64(&bat.energy_now_wh).set_style(theme::VALUE),
|
|
" Wh".set_style(theme::VALUE),
|
|
" / ".set_style(theme::VALUE),
|
|
crate::battery::BatteryInfo::display_f64(&bat.energy_full_wh).set_style(theme::VALUE),
|
|
" Wh".set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Health: ".set_style(theme::LABEL),
|
|
crate::battery::BatteryInfo::display_u32(&bat.health_percent()).set_style(theme::VALUE),
|
|
"%".set_style(theme::VALUE),
|
|
" (current charge / full charge)".set_style(theme::VALUE_OFF),
|
|
]));
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("Power".set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(vec![
|
|
" Power: ".set_style(theme::LABEL),
|
|
crate::battery::BatteryInfo::display_f64(&bat.power_now_w).set_style(theme::VALUE),
|
|
" W".set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Voltage: ".set_style(theme::LABEL),
|
|
crate::battery::BatteryInfo::display_f64(&bat.voltage_now_v).set_style(theme::VALUE),
|
|
" V".set_style(theme::VALUE),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Time to empty: ".set_style(theme::LABEL),
|
|
if let Some(s) = bat.time_to_empty_s {
|
|
crate::battery::BatteryInfo::format_duration(s).set_style(theme::VALUE)
|
|
} else {
|
|
"?".set_style(theme::VALUE_OFF)
|
|
},
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Time to full: ".set_style(theme::LABEL),
|
|
if let Some(s) = bat.time_to_full_s {
|
|
crate::battery::BatteryInfo::format_duration(s).set_style(theme::VALUE)
|
|
} else {
|
|
"?".set_style(theme::VALUE_OFF)
|
|
},
|
|
]));
|
|
Paragraph::new(lines)
|
|
.block(panel_border(focused, " Battery ", &app.theme))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
pub fn render_sensor_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|
let sensors = &app.sensors;
|
|
if sensors.is_empty() {
|
|
return Paragraph::new(Line::from(
|
|
"(no sensors detected — /sys/class/hwmon/ not readable)".set_style(theme::VALUE_WARM),
|
|
))
|
|
.block(panel_border(focused, " Sensors ", &app.theme))
|
|
.wrap(Wrap { trim: true });
|
|
}
|
|
let mut lines: Vec<Line<'a>> = Vec::new();
|
|
lines.push(Line::from(
|
|
format!(
|
|
"Detected {} chip(s), {} sensor(s) total:",
|
|
sensors.chips.len(),
|
|
sensors.total_readings()
|
|
)
|
|
.set_style(theme::LABEL_BOLD),
|
|
));
|
|
lines.push(Line::from(""));
|
|
for chip in &sensors.chips {
|
|
lines.push(Line::from(
|
|
format!("▸ {}", chip.name).set_style(theme::LABEL_BOLD),
|
|
));
|
|
for reading in &chip.readings {
|
|
let label_str = reading.label.as_deref().unwrap_or(reading.kind.name());
|
|
lines.push(Line::from(vec![
|
|
" ".into(),
|
|
format!("{:<12}", label_str).set_style(theme::LABEL),
|
|
format!("{:>14}", reading.display_value).set_style(theme::VALUE),
|
|
]));
|
|
}
|
|
lines.push(Line::from(""));
|
|
}
|
|
Paragraph::new(lines)
|
|
.block(panel_border(focused, " Sensors ", &app.theme))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
pub fn render_network_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|
let net = &app.net;
|
|
if net.is_empty() {
|
|
return Paragraph::new(Line::from(
|
|
"(no network interfaces detected — /sys/class/net/ not readable)"
|
|
.set_style(theme::VALUE_WARM),
|
|
))
|
|
.block(panel_border(focused, " Network ", &app.theme))
|
|
.wrap(Wrap { trim: true });
|
|
}
|
|
let mut lines: Vec<Line<'a>> = Vec::new();
|
|
lines.push(Line::from(
|
|
format!("Detected {} interface(s):", net.count()).set_style(theme::LABEL_BOLD),
|
|
));
|
|
lines.push(Line::from(""));
|
|
for iface in &net.interfaces {
|
|
lines.push(Line::from(
|
|
format!("▸ {}", iface.name).set_style(theme::LABEL_BOLD),
|
|
));
|
|
lines.push(Line::from(vec![
|
|
" State: ".set_style(theme::LABEL),
|
|
iface
|
|
.operstate
|
|
.as_deref()
|
|
.unwrap_or("?")
|
|
.set_style(theme::VALUE),
|
|
]));
|
|
if let Some(mac) = &iface.mac_address
|
|
&& !mac.is_empty()
|
|
&& mac != "00:00:00:00:00:00"
|
|
{
|
|
lines.push(Line::from(vec![
|
|
" MAC: ".set_style(theme::LABEL),
|
|
mac.clone().set_style(theme::VALUE),
|
|
]));
|
|
}
|
|
if let Some(mtu) = iface.mtu {
|
|
lines.push(Line::from(vec![
|
|
" MTU: ".set_style(theme::LABEL),
|
|
mtu.to_string().set_style(theme::VALUE),
|
|
]));
|
|
}
|
|
if let Some(speed) = iface.speed_mbps
|
|
&& speed > 0
|
|
{
|
|
lines.push(Line::from(vec![
|
|
" Speed: ".set_style(theme::LABEL),
|
|
format!("{} Mbps", speed).set_style(theme::VALUE),
|
|
]));
|
|
}
|
|
lines.push(Line::from(vec![
|
|
" RX bytes: ".set_style(theme::LABEL),
|
|
crate::network::NetInterface::format_bytes(iface.rx_bytes).set_style(theme::VALUE),
|
|
format!(
|
|
" ({} packets, {} err, {} drop, {:.1} KiB/s)",
|
|
iface.rx_packets, iface.rx_errors, iface.rx_dropped, iface.rx_kbps
|
|
)
|
|
.set_style(theme::VALUE_OFF),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" TX bytes: ".set_style(theme::LABEL),
|
|
crate::network::NetInterface::format_bytes(iface.tx_bytes).set_style(theme::VALUE),
|
|
format!(
|
|
" ({} packets, {} err, {} drop, {:.1} KiB/s)",
|
|
iface.tx_packets, iface.tx_errors, iface.tx_dropped, iface.tx_kbps
|
|
)
|
|
.set_style(theme::VALUE_OFF),
|
|
]));
|
|
if !iface.ipv6_addrs.is_empty() {
|
|
lines.push(Line::from(" IPv6:".set_style(theme::LABEL)));
|
|
for addr in &iface.ipv6_addrs {
|
|
lines.push(Line::from(format!(" {}", addr).set_style(theme::VALUE)));
|
|
}
|
|
}
|
|
lines.push(Line::from(""));
|
|
}
|
|
Paragraph::new(lines)
|
|
.block(panel_border(focused, " Network ", &app.theme))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
pub fn render_storage_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|
let storage = &app.storage;
|
|
if storage.is_empty() {
|
|
return Paragraph::new(Line::from(
|
|
"(no storage devices detected — /sys/block/ not readable)".set_style(theme::VALUE_WARM),
|
|
))
|
|
.block(panel_border(focused, " Storage ", &app.theme))
|
|
.wrap(Wrap { trim: true });
|
|
}
|
|
let mut lines: Vec<Line<'a>> = Vec::new();
|
|
lines.push(Line::from(
|
|
format!("Detected {} disk(s):", storage.count()).set_style(theme::LABEL_BOLD),
|
|
));
|
|
lines.push(Line::from(""));
|
|
for disk in &storage.disks {
|
|
let smart_badge = if !app.smart.available {
|
|
" (SMART: install smartmontools)".to_string()
|
|
} else if let Some(health) = app.smart.health_for(&disk.name) {
|
|
if let Some(err) = &health.error {
|
|
format!(" (SMART: {})", err)
|
|
} else if health.passed {
|
|
" ✓ PASSED".to_string()
|
|
} else {
|
|
" ✗ FAILED".to_string()
|
|
}
|
|
} else {
|
|
String::new()
|
|
};
|
|
lines.push(Line::from(
|
|
format!("▸ {} ({}){}", disk.name, disk.kind_label(), smart_badge)
|
|
.set_style(theme::LABEL_BOLD),
|
|
));
|
|
// v1.38: per-disk I/O throughput history sparkline.
|
|
// 12 samples, normalized 0..=255 against the disk's
|
|
// own max (same pattern as the per-PID IO-RATE column
|
|
// in the Process tab).
|
|
let disk_sparkline: String = app
|
|
.disk_history
|
|
.get(&disk.name)
|
|
.map(|hist| {
|
|
let bytes: Vec<u8> = hist.iter().copied().collect();
|
|
io_rate_sparkline(&bytes)
|
|
})
|
|
.unwrap_or_else(|| " ".repeat(crate::app::PROCESS_IO_HISTORY_LEN));
|
|
lines.push(Line::from(
|
|
format!(" Throughput: {}", disk_sparkline).set_style(theme::VALUE),
|
|
));
|
|
if let Some(model) = &disk.model {
|
|
lines.push(Line::from(vec![
|
|
" Model: ".set_style(theme::LABEL),
|
|
model.clone().set_style(theme::VALUE),
|
|
]));
|
|
}
|
|
if let Some(vendor) = &disk.vendor {
|
|
let trimmed = vendor.trim();
|
|
if !trimmed.is_empty() {
|
|
lines.push(Line::from(vec![
|
|
" Vendor: ".set_style(theme::LABEL),
|
|
trimmed.set_style(theme::VALUE),
|
|
]));
|
|
}
|
|
}
|
|
lines.push(Line::from(vec![
|
|
" Size: ".set_style(theme::LABEL),
|
|
crate::storage::DiskInfo::format_size(disk.size_bytes).set_style(theme::VALUE),
|
|
]));
|
|
if let Some(sched) = &disk.scheduler {
|
|
let sched_trimmed: String = sched.chars().take(60).collect();
|
|
lines.push(Line::from(vec![
|
|
" Scheduler:".set_style(theme::LABEL),
|
|
format!(" {}", sched_trimmed).set_style(theme::VALUE),
|
|
]));
|
|
}
|
|
if let Some(qd) = disk.queue_depth {
|
|
lines.push(Line::from(vec![
|
|
" Queue: ".set_style(theme::LABEL),
|
|
format!("{} requests", qd).set_style(theme::VALUE),
|
|
]));
|
|
}
|
|
lines.push(Line::from(vec![
|
|
" Read: ".set_style(theme::LABEL),
|
|
crate::storage::DiskInfo::format_size(disk.stats.read_bytes).set_style(theme::VALUE),
|
|
format!(
|
|
" ({} I/Os, {:.1} KiB/s)",
|
|
disk.stats.reads_completed, disk.stats.read_kbps
|
|
)
|
|
.set_style(theme::VALUE_OFF),
|
|
]));
|
|
lines.push(Line::from(vec![
|
|
" Written: ".set_style(theme::LABEL),
|
|
crate::storage::DiskInfo::format_size(disk.stats.write_bytes).set_style(theme::VALUE),
|
|
format!(
|
|
" ({} I/Os, {:.1} KiB/s)",
|
|
disk.stats.writes_completed, disk.stats.write_kbps
|
|
)
|
|
.set_style(theme::VALUE_OFF),
|
|
]));
|
|
if !disk.partitions.is_empty() {
|
|
lines.push(Line::from(vec![
|
|
" Parts: ".set_style(theme::LABEL),
|
|
disk.partitions.join(", ").set_style(theme::VALUE),
|
|
]));
|
|
}
|
|
lines.push(Line::from(""));
|
|
}
|
|
Paragraph::new(lines)
|
|
.block(panel_border(focused, " Storage ", &app.theme))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|
let proc = &app.processes;
|
|
if proc.is_empty() {
|
|
return Paragraph::new(Line::from(
|
|
"(no processes detected — /proc/ not readable)".set_style(theme::VALUE_WARM),
|
|
))
|
|
.block(panel_border(focused, " Process ", &app.theme))
|
|
.wrap(Wrap { trim: true });
|
|
}
|
|
let mut lines: Vec<Line<'a>> = Vec::new();
|
|
let filter_indicator = if app.process_filter.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("; filter: \"{}\" (press Esc to clear)", app.process_filter)
|
|
};
|
|
let sort_arrow = if app.sort_ascending {
|
|
"\u{25B2}"
|
|
} else {
|
|
"\u{25BC}"
|
|
};
|
|
let mut running = 0u32;
|
|
let mut sleeping = 0u32;
|
|
let mut zombie = 0u32;
|
|
let mut stopped = 0u32;
|
|
for p in &proc.processes {
|
|
match p.state {
|
|
'R' => running += 1,
|
|
'Z' => zombie += 1,
|
|
'T' | 't' => stopped += 1,
|
|
_ => sleeping += 1,
|
|
}
|
|
}
|
|
let state_summary = format!("R:{running} S:{sleeping} Z:{zombie} T:{stopped}");
|
|
lines.push(Line::from(format!(
|
|
"Showing top {} of {} process(es) [{state_summary}]; total RSS: {}; sort: {} {}{}{} (press 'o' to cycle, 'y' for tree, 'F' to filter)",
|
|
proc.count(),
|
|
proc.total_count,
|
|
crate::process::ProcessInfo::format_memory_kb(proc.total_memory_kb),
|
|
app.process_sort.name(),
|
|
sort_arrow,
|
|
if app.process_tree { "; view: tree" } else { "" },
|
|
filter_indicator,
|
|
).set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(""));
|
|
// The MEM column header swaps between RSS and VSZ depending on
|
|
// the active sort. Default and most modes use RSS (resident
|
|
// set, physical memory); SortMode::VSize uses VSZ (virtual
|
|
// address space) so the column being sorted IS the column
|
|
// being shown. No new column is added; this keeps the panel
|
|
// width bounded.
|
|
let mem_header = match app.process_sort {
|
|
crate::process::SortMode::VSize => "VSZ",
|
|
crate::process::SortMode::RChar => "RChr",
|
|
crate::process::SortMode::WChar => "WChr",
|
|
_ => "RSS",
|
|
};
|
|
let header_str = format!(
|
|
" PID STATE SCHED PRIO NI THR CPU% IO RATE {:<11} T-IO T-IO/s IO-RATE CPU% RSS AFF COMM",
|
|
mem_header
|
|
);
|
|
lines.push(Line::from(vec![header_str.set_style(theme::LABEL)]));
|
|
let filter_lower = app.process_filter.to_lowercase();
|
|
let mut visible_index: usize = 0;
|
|
for p in &proc.processes {
|
|
if !app.process_filter.is_empty() && !p.comm.to_lowercase().contains(&filter_lower) {
|
|
continue;
|
|
}
|
|
let is_cursor =
|
|
app.current_tab == TabId::Process && visible_index == app.process_cursor && focused;
|
|
visible_index += 1;
|
|
let display_name: String = if app.show_full_cmdline {
|
|
p.cmdline
|
|
.as_ref()
|
|
.map(|c| c.chars().take(40).collect())
|
|
.unwrap_or_else(|| p.comm.chars().take(20).collect())
|
|
} else {
|
|
p.comm.chars().take(20).collect()
|
|
};
|
|
let io_str = match p.io_total_kb() {
|
|
Some(kb) => crate::process::ProcessInfo::format_memory_kb(kb),
|
|
None => "—".to_string(),
|
|
};
|
|
let rate_str = match p.io_total_rate_kbs() {
|
|
Some(kbs) => crate::process::ProcessInfo::format_rate_kbs(kbs),
|
|
None => "—".to_string(),
|
|
};
|
|
let per_thread_str = match p.io_per_thread_rate_kbs() {
|
|
Some(kbs) => crate::process::ProcessInfo::format_rate_kbs(kbs),
|
|
None => "—".to_string(),
|
|
};
|
|
// v1.41: per-thread IO total (read + write bytes,
|
|
// aggregated across all threads). Independent of
|
|
// the process-total IO column. May be larger than
|
|
// the process total on kernels that double-count
|
|
// thread-attributed IO to the process; smaller on
|
|
// kernels where /proc/[pid]/io is the only source
|
|
// of truth and threads aren't separately reported.
|
|
let thread_io_total_str = match (p.thread_io_read_kb, p.thread_io_write_kb) {
|
|
(Some(r), Some(w)) => {
|
|
crate::process::ProcessInfo::format_memory_kb(r.saturating_add(w))
|
|
}
|
|
_ => "—".to_string(),
|
|
};
|
|
// v1.42: CPU affinity indicator. Single char so
|
|
// it doesn't push COMM off the visible area.
|
|
// * process is pinned to a subset of CPUs
|
|
// (affinity != all CPUs)
|
|
// (space) process is on all CPUs (default)
|
|
// ? affinity unknown (older kernel / Redox)
|
|
// The full list is in the PID detail popup.
|
|
let host_cpu_count = crate::acpi::detect_cpus().len();
|
|
let affinity_indicator = match &p.cpu_affinity {
|
|
None => "?",
|
|
Some(ids) => {
|
|
// "All CPUs" = the affinity list is
|
|
// the same length as the host's CPU
|
|
// list. We can't compare specific IDs
|
|
// here (host CPUs may have non-
|
|
// contiguous IDs on some machines),
|
|
// so we approximate by count. If the
|
|
// counts match, we assume the process
|
|
// is unrestricted.
|
|
if ids.len() >= host_cpu_count {
|
|
" "
|
|
} else {
|
|
"*"
|
|
}
|
|
}
|
|
};
|
|
let mem_str = match app.process_sort {
|
|
crate::process::SortMode::VSize => {
|
|
crate::process::ProcessInfo::format_memory_kb(p.vsize_kb)
|
|
}
|
|
crate::process::SortMode::RChar => {
|
|
crate::process::ProcessInfo::format_memory_kb(p.io_rchar_kb)
|
|
}
|
|
crate::process::SortMode::WChar => {
|
|
crate::process::ProcessInfo::format_memory_kb(p.io_wchar_kb)
|
|
}
|
|
_ => crate::process::ProcessInfo::format_memory_kb(p.rss_kb),
|
|
};
|
|
let mem_pct = if app.meminfo.total_kib > 0 {
|
|
format!(
|
|
" ({:.1}%)",
|
|
p.rss_kb as f64 * 100.0 / app.meminfo.total_kib as f64
|
|
)
|
|
} else {
|
|
String::new()
|
|
};
|
|
// Three per-PID sparklines: IO-RATE (12 samples),
|
|
// CPU% (6 samples), RSS (6 samples). The smaller
|
|
// CPU/RSS sparklines are 6 chars wide to keep the
|
|
// panel within a 120-col terminal.
|
|
let io_spark = app
|
|
.io_history
|
|
.get(&p.pid)
|
|
.map(|hist| {
|
|
let bytes: Vec<u8> = hist.iter().copied().collect();
|
|
io_rate_sparkline(&bytes)
|
|
})
|
|
.unwrap_or_else(|| " ".repeat(crate::app::PROCESS_IO_HISTORY_LEN));
|
|
let cpu_spark = sparkline_short(&app.cpu_history, &p.pid, 6);
|
|
let rss_spark = sparkline_short(&app.rss_history, &p.pid, 6);
|
|
let prefix = if app.process_tree {
|
|
tree_prefix(p.pid, p.ppid, &proc.processes, &app.folded)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let row_style = if is_cursor {
|
|
app.theme.cursor_highlight
|
|
} else {
|
|
theme::VALUE
|
|
};
|
|
let cpu_str = format!("{:<6}", format!("{:.1}", p.cpu_pct));
|
|
let cpu_style = if p.cpu_pct >= 90.0 {
|
|
theme::VALUE_HOT_LIGHT
|
|
} else if p.cpu_pct >= 50.0 {
|
|
theme::VALUE_HOT
|
|
} else if p.cpu_pct >= 10.0 {
|
|
theme::VALUE_WARM
|
|
} else {
|
|
theme::VALUE
|
|
};
|
|
let pre1a = format!(" {}{:<7} ", prefix, p.pid);
|
|
let state_style = match p.state {
|
|
'R' => theme::VALUE_OK,
|
|
'D' => theme::VALUE_HOT,
|
|
'Z' => theme::VALUE_OFF,
|
|
'T' => theme::VALUE_WARM,
|
|
_ => theme::VALUE,
|
|
};
|
|
let pre1b = format!(" {:<5} {:<4} ", p.sched_policy, p.priority);
|
|
let nice_str = format!("{:<3}", p.nice);
|
|
let nice_style = if p.nice < 0 {
|
|
theme::VALUE_OK
|
|
} else if p.nice > 0 {
|
|
theme::VALUE_WARM
|
|
} else {
|
|
theme::VALUE
|
|
};
|
|
let pre2 = format!(" {:<3} ", p.num_threads);
|
|
let post = format!(
|
|
" {:<11} {:<11} {:<11} {:<11} {:<11}{:<7} {:<12} {:<6} {:<5} {:<3} ",
|
|
io_str,
|
|
rate_str,
|
|
thread_io_total_str,
|
|
per_thread_str,
|
|
mem_str,
|
|
mem_pct,
|
|
io_spark,
|
|
cpu_spark,
|
|
rss_spark,
|
|
affinity_indicator,
|
|
);
|
|
let mut spans = vec![
|
|
Span::styled(pre1a, row_style),
|
|
Span::styled(
|
|
p.state.to_string(),
|
|
if is_cursor {
|
|
app.theme.cursor_highlight
|
|
} else {
|
|
state_style
|
|
},
|
|
),
|
|
Span::styled(pre1b, row_style),
|
|
Span::styled(
|
|
nice_str,
|
|
if is_cursor {
|
|
app.theme.cursor_highlight
|
|
} else {
|
|
nice_style
|
|
},
|
|
),
|
|
Span::styled(pre2, row_style),
|
|
];
|
|
if is_cursor {
|
|
spans.push(Span::styled(cpu_str, app.theme.cursor_highlight));
|
|
} else {
|
|
spans.push(Span::styled(cpu_str, cpu_style));
|
|
}
|
|
spans.push(Span::styled(post, row_style));
|
|
if !filter_lower.is_empty() && display_name.to_lowercase().contains(&filter_lower) {
|
|
let lower = display_name.to_lowercase();
|
|
let mut last = 0;
|
|
while let Some(pos) = lower[last..].find(&filter_lower) {
|
|
let abs = last + pos;
|
|
if abs > last {
|
|
spans.push(Span::styled(display_name[last..abs].to_string(), row_style));
|
|
}
|
|
spans.push(Span::styled(
|
|
display_name[abs..abs + filter_lower.len()].to_string(),
|
|
row_style.add_modifier(
|
|
ratatui::style::Modifier::BOLD | ratatui::style::Modifier::REVERSED,
|
|
),
|
|
));
|
|
last = abs + filter_lower.len();
|
|
}
|
|
if last < display_name.len() {
|
|
spans.push(Span::styled(display_name[last..].to_string(), row_style));
|
|
}
|
|
} else {
|
|
spans.push(Span::styled(display_name, row_style));
|
|
}
|
|
lines.push(Line::from(spans));
|
|
}
|
|
Paragraph::new(lines)
|
|
.block(panel_border(focused, " Process ", &app.theme))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
/// Build a tree prefix string for a process. htop-style vertical
|
|
/// bars: each ancestor level renders either `│ ` (the path
|
|
/// continues below) or ` ` (the path is the last child of its
|
|
/// ancestor). The row itself renders `└─ ` (last child) or `├─ `
|
|
/// (non-last child), or no connector for roots. Walks the ppid
|
|
/// chain to determine depth and uses the next row in `all` to
|
|
/// decide whether each ancestor has a continuation below.
|
|
///
|
|
/// O(N) per call, O(N^2) worst case for the full render. Fine for
|
|
/// the truncated top-50 list.
|
|
#[cfg(test)]
|
|
pub(crate) fn tree_prefix_for_test(
|
|
pid: u32,
|
|
ppid: u32,
|
|
all: &[crate::process::ProcessInfo],
|
|
folded: &std::collections::BTreeSet<u32>,
|
|
) -> String {
|
|
tree_prefix(pid, ppid, all, folded)
|
|
}
|
|
|
|
fn tree_prefix(
|
|
pid: u32,
|
|
ppid: u32,
|
|
all: &[crate::process::ProcessInfo],
|
|
folded: &std::collections::BTreeSet<u32>,
|
|
) -> String {
|
|
use std::collections::HashMap;
|
|
if all.is_empty() {
|
|
return String::new();
|
|
}
|
|
let by_pid: HashMap<u32, &crate::process::ProcessInfo> =
|
|
all.iter().map(|p| (p.pid, p)).collect();
|
|
|
|
// Walk up the ppid chain. Each step means this row is one level
|
|
// deeper than its parent. Record the chain of ancestors (from
|
|
// parent up to root) for the bar-continuation check below.
|
|
// Stop when we hit a root (ppid==0 or ppid not in list) or a
|
|
// cycle (safety bound of 64 hops).
|
|
let mut ancestors: Vec<u32> = Vec::new();
|
|
let mut cur = pid;
|
|
let max_walk = 64;
|
|
for _ in 0..max_walk {
|
|
let p = match by_pid.get(&cur) {
|
|
Some(p) => *p,
|
|
None => break,
|
|
};
|
|
if p.ppid == 0 || !by_pid.contains_key(&p.ppid) {
|
|
break;
|
|
}
|
|
ancestors.push(p.ppid);
|
|
cur = p.ppid;
|
|
}
|
|
let depth = ancestors.len();
|
|
|
|
// "Last child" of immediate parent: this row is last iff the
|
|
// next row in the list has a different ppid (or there is no
|
|
// next row).
|
|
let my_index = match all.iter().position(|p| p.pid == pid) {
|
|
Some(i) => i,
|
|
None => return String::new(),
|
|
};
|
|
let next_ppid = all.get(my_index + 1).map(|n| n.ppid);
|
|
let is_last = next_ppid != Some(ppid);
|
|
|
|
// For each ancestor level, decide if the bar continues. The
|
|
// bar at depth d (counting from 0 for the row's immediate
|
|
// parent) continues iff the NEXT row in the list is still a
|
|
// descendant of `ancestor` — either directly (next row's
|
|
// ppid == ancestor) or through a deeper chain. The v1.34
|
|
// implementation walked the next row's chain only `0..=d`
|
|
// steps which missed the case where the next row is a deeper
|
|
// descendant of `ancestor`. v1.37 walks the full chain.
|
|
let mut bar = String::new();
|
|
for &ancestor in ancestors.iter() {
|
|
let next_row = all.get(my_index + 1);
|
|
let ancestor_active_in_next = next_row.is_some_and(|n| {
|
|
if n.ppid == ancestor {
|
|
return true;
|
|
}
|
|
// Walk the next row's full ancestor chain looking
|
|
// for `ancestor`. Walk is bounded at 64 hops for
|
|
// cycle safety.
|
|
let mut cur = n.pid;
|
|
for _ in 0..64 {
|
|
let np = match by_pid.get(&cur) {
|
|
Some(np) => *np,
|
|
None => return false,
|
|
};
|
|
if np.ppid == 0 {
|
|
return false;
|
|
}
|
|
if np.ppid == ancestor {
|
|
return true;
|
|
}
|
|
cur = np.ppid;
|
|
}
|
|
false
|
|
});
|
|
bar.push_str(if ancestor_active_in_next {
|
|
"│ "
|
|
} else {
|
|
" "
|
|
});
|
|
}
|
|
|
|
// Fold/unfold marker.
|
|
let has_children = all.iter().any(|p| p.ppid == pid);
|
|
let is_folded = folded.contains(&pid);
|
|
let fold_marker = if has_children {
|
|
if is_folded { "▶ " } else { "▼ " }
|
|
} else {
|
|
" "
|
|
};
|
|
|
|
if depth == 0 {
|
|
// Root: no connector, just the fold marker.
|
|
return format!("{}{}", bar, fold_marker);
|
|
}
|
|
|
|
let connector = if is_last { "└─ " } else { "├─ " };
|
|
format!("{}{}{}", bar, connector, fold_marker)
|
|
}
|
|
|
|
pub fn render_pid_detail(
|
|
detail: &crate::pid_detail::PidDetail,
|
|
pid: u32,
|
|
theme: &Theme,
|
|
) -> Paragraph<'static> {
|
|
let s = &detail.status;
|
|
let i = &detail.io;
|
|
let sm = &detail.smaps;
|
|
let opt = |x: &Option<u64>| {
|
|
x.map(|v| format!("{}", v))
|
|
.unwrap_or_else(|| "?".to_string())
|
|
};
|
|
let opt_kb = |x: &Option<u64>| {
|
|
x.map(|v| format!("{} KiB", v))
|
|
.unwrap_or_else(|| "?".to_string())
|
|
};
|
|
let opt_str = |x: &Option<String>| x.clone().unwrap_or_else(|| "?".to_string());
|
|
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
lines.push(Line::from(
|
|
format!("═══ PID {} Detail (press any key to close) ═══", pid).set_style(theme::LABEL_BOLD),
|
|
));
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("[Identity]".set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(
|
|
format!(" Name: {}", opt_str(&s.name)).set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(" State: {}", opt_str(&s.state)).set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" Pid: {} PPid: {} Tgid: {}",
|
|
opt(&s.pid),
|
|
opt(&s.ppid),
|
|
opt(&s.pgrp)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(" Threads: {}", opt(&s.threads)).set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" Uid: {}/{}/{} Gid: {}/{}/{}",
|
|
opt(&s.uid_real),
|
|
opt(&s.uid_effective),
|
|
opt(&s.uid_saved),
|
|
opt(&s.gid_real),
|
|
opt(&s.gid_effective),
|
|
opt(&s.gid_saved)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
// v1.38: cmdline (htop parity). Long cmdlines are
|
|
// truncated to fit the popup; the full string is available
|
|
// via scrollback in a real terminal.
|
|
let cmdline = detail.cmdline.as_deref().unwrap_or("?");
|
|
let cmdline_trunc: String = cmdline.chars().take(120).collect();
|
|
lines.push(Line::from(
|
|
format!(
|
|
" Cmdline: {}",
|
|
if cmdline_trunc.is_empty() {
|
|
"?".to_string()
|
|
} else {
|
|
cmdline_trunc
|
|
}
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
let io_pri = detail
|
|
.io_priority
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| "?".to_string());
|
|
lines.push(Line::from(
|
|
format!(" IO priority: {}", io_pri).set_style(theme::VALUE),
|
|
));
|
|
// v1.39: environ (htop F7 parity). Render the first
|
|
// 8 vars as KEY=VALUE; the full list is unbounded so we
|
|
// truncate for the popup. Sorted by key in `read_environ`
|
|
// for a stable, predictable list.
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("[environ]".set_style(theme::LABEL_BOLD)));
|
|
match &detail.environ {
|
|
Some(vars) if !vars.is_empty() => {
|
|
lines.push(Line::from(
|
|
format!(" ({} variables)", vars.len()).set_style(theme::VALUE),
|
|
));
|
|
for (k, v) in vars.iter().take(8) {
|
|
let v_trunc: String = v.chars().take(80).collect();
|
|
lines.push(Line::from(
|
|
format!(
|
|
" {}={}",
|
|
k,
|
|
if v_trunc.is_empty() {
|
|
String::from("\"\"")
|
|
} else {
|
|
v_trunc
|
|
}
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
}
|
|
if vars.len() > 8 {
|
|
lines.push(Line::from(
|
|
format!(" ... and {} more", vars.len() - 8).set_style(theme::VALUE),
|
|
));
|
|
}
|
|
}
|
|
Some(_) => {
|
|
lines.push(Line::from(" (empty)").set_style(theme::VALUE));
|
|
}
|
|
None => {
|
|
lines.push(Line::from(" (unavailable)").set_style(theme::VALUE));
|
|
}
|
|
}
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("[Memory]".set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" VmPeak: {} VmRSS: {}",
|
|
opt_kb(&s.vm_peak_kb),
|
|
opt_kb(&s.vm_rss_kb)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" VmSize: {} VmHWM: {}",
|
|
opt_kb(&s.vm_size_kb),
|
|
opt_kb(&s.vm_hwm_kb)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" VmData: {} VmStk: {} VmExe: {}",
|
|
opt_kb(&s.vm_data_kb),
|
|
opt_kb(&s.vm_stk_kb),
|
|
opt_kb(&s.vm_exe_kb)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" VmLib: {} VmPTE: {} VmSwap: {}",
|
|
opt_kb(&s.vm_lib_kb),
|
|
opt_kb(&s.vm_pte_kb),
|
|
opt_kb(&s.vm_swap_kb)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("[smaps_rollup]".set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" Rss: {} Pss: {} Swapped: {}",
|
|
opt_kb(&sm.rss_kb),
|
|
opt_kb(&sm.pss_kb),
|
|
opt_kb(&sm.swapped_kb)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" Private_Clean: {} Private_Dirty: {}",
|
|
opt_kb(&sm.private_clean_kb),
|
|
opt_kb(&sm.private_dirty_kb)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("[io]".set_style(theme::LABEL_BOLD)));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" rchar: {} wchar: {}",
|
|
opt(&i.rchar),
|
|
opt(&i.wchar)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" read_bytes:{} write_bytes:{}",
|
|
opt(&i.read_bytes),
|
|
opt(&i.write_bytes)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" syscr: {} syscw: {}",
|
|
opt(&i.syscr),
|
|
opt(&i.syscw)
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(" cancelled_write_bytes: {}", opt(&i.cancelled_write_bytes))
|
|
.set_style(theme::VALUE),
|
|
));
|
|
// v1.41: per-thread IO aggregated across all threads.
|
|
// Read directly here (not via PidDetail) because the
|
|
// Process panel keeps these fields on ProcessInfo,
|
|
// not on the popup's PidDetail. We re-read on
|
|
// popup open so the value is current.
|
|
let (thread_read, thread_write) = crate::process::read_thread_io_for_pid(pid);
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from(
|
|
"[thread_io] (sum across /proc/[pid]/task/*/io)".set_style(theme::LABEL_BOLD),
|
|
));
|
|
lines.push(Line::from(
|
|
format!(
|
|
" thread read_bytes: {} thread write_bytes: {}",
|
|
opt_kb(&thread_read),
|
|
opt_kb(&thread_write),
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
// v1.42: CPU affinity (full list, expanded form).
|
|
// Re-read on popup open so the value is current.
|
|
// Show count + compact range string + (on next
|
|
// line) the expanded Vec. On small machines the
|
|
// expanded list fits inline; on large machines
|
|
// (>32 CPUs) we truncate to "first 8 + N more".
|
|
let affinity = crate::process::read_cpu_affinity_for_pid(pid);
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("[cpu_affinity]".set_style(theme::LABEL_BOLD)));
|
|
match &affinity {
|
|
None => {
|
|
lines.push(Line::from(" (unavailable)".set_style(theme::VALUE)));
|
|
}
|
|
Some(ids) if ids.is_empty() => {
|
|
lines.push(Line::from(
|
|
" (empty — process pinned to no CPUs!)".set_style(theme::VALUE),
|
|
));
|
|
}
|
|
Some(ids) => {
|
|
let compact = crate::process::format_cpu_list(ids);
|
|
lines.push(Line::from(
|
|
format!(
|
|
" Cpus_allowed_list: {} ({} CPU{})",
|
|
compact,
|
|
ids.len(),
|
|
if ids.len() == 1 { "" } else { "s" },
|
|
)
|
|
.set_style(theme::VALUE),
|
|
));
|
|
// Show the expanded Vec. Truncate for
|
|
// large machines to keep the popup
|
|
// readable.
|
|
let display: Vec<u32> = if ids.len() > 8 {
|
|
ids.iter().take(8).copied().collect()
|
|
} else {
|
|
ids.clone()
|
|
};
|
|
let mut expanded = display
|
|
.iter()
|
|
.map(|c| c.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
if ids.len() > 8 {
|
|
expanded.push_str(&format!(", ... ({} more)", ids.len() - 8));
|
|
}
|
|
lines.push(Line::from(
|
|
format!(" expanded: [{}]", expanded).set_style(theme::VALUE),
|
|
));
|
|
}
|
|
}
|
|
Paragraph::new(lines)
|
|
.block(panel_border(true, " PID Detail ", theme))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
pub fn render_cpu_table<'a>(
|
|
cpus: &'a [CpuRow],
|
|
expanded_cpu: Option<u32>,
|
|
focused: bool,
|
|
pkg_power_w: Option<f64>,
|
|
rapl_status: &'a str,
|
|
per_cpu_irqs: &'a [u64],
|
|
theme: &Theme,
|
|
) -> Table<'a> {
|
|
let header = Row::new(vec![
|
|
"CPU".set_style(theme::LABEL),
|
|
"Freq/MHz".set_style(theme::LABEL),
|
|
"PkgW".set_style(theme::LABEL),
|
|
"Temp°C bar".set_style(theme::LABEL),
|
|
"P-state".set_style(theme::LABEL),
|
|
"State".set_style(theme::LABEL),
|
|
"Flags".set_style(theme::LABEL),
|
|
"Load % (30s)".set_style(theme::LABEL),
|
|
"IRQs".set_style(theme::LABEL),
|
|
])
|
|
.height(1);
|
|
let rows: Vec<Row> = cpus
|
|
.iter()
|
|
.map(|cpu| {
|
|
let freq_mhz = cpu.freq_khz / 1000;
|
|
let freq_style = if !cpu.pstates.is_empty() {
|
|
let max_f = cpu.pstates.first().map(|p| p.freq_khz).unwrap_or(0) / 1000;
|
|
let min_f = cpu.pstates.last().map(|p| p.freq_khz).unwrap_or(0) / 1000;
|
|
if max_f > min_f && freq_mhz > 0 {
|
|
let pct = (freq_mhz - min_f) as f64 * 100.0 / (max_f - min_f) as f64;
|
|
match pct as u32 {
|
|
p if p >= 85 => theme::VALUE_HOT,
|
|
p if p >= 50 => theme::VALUE_WARM,
|
|
p if p <= 15 => Style::new().dark_gray(),
|
|
_ => theme::VALUE,
|
|
}
|
|
} else {
|
|
theme::VALUE
|
|
}
|
|
} else {
|
|
theme::VALUE
|
|
};
|
|
let freq = if cpu.freq_khz > 0 {
|
|
format!("{}", freq_mhz)
|
|
} else {
|
|
"?".into()
|
|
};
|
|
let temp_cell: Cell = match cpu.temp_c {
|
|
Some(t) => {
|
|
let pct = t.min(100) as u8;
|
|
let bar = horizontal_bar(pct, 4);
|
|
let color = theme::temp_color(cpu.temp_c);
|
|
let mut temp_style = Style::new().fg(color);
|
|
if t >= 85 {
|
|
temp_style = temp_style.add_modifier(ratatui::style::Modifier::BOLD);
|
|
}
|
|
if t >= 95 {
|
|
temp_style = temp_style.add_modifier(ratatui::style::Modifier::REVERSED);
|
|
}
|
|
Cell::from(Line::from(vec![
|
|
format!("{t:>3} ").set_style(temp_style),
|
|
bar.set_style(Style::new().fg(color)),
|
|
]))
|
|
}
|
|
None => Cell::from("n/a".set_style(theme::VALUE_OFF)),
|
|
};
|
|
let pstate = cpu
|
|
.current_idx
|
|
.map(|i| format!("P{i}"))
|
|
.unwrap_or_else(|| "?".into());
|
|
let mut flags = String::new();
|
|
if cpu.prochot {
|
|
flags.push('H');
|
|
}
|
|
if cpu.critical {
|
|
flags.push('C');
|
|
}
|
|
if cpu.power_limit {
|
|
flags.push('L');
|
|
}
|
|
let flags_cell: Cell = if flags.is_empty() {
|
|
Cell::from("-".set_style(theme::NO_FLAG))
|
|
} else {
|
|
let color = theme::flags_color(cpu.critical, cpu.prochot, cpu.power_limit);
|
|
Cell::from(flags.clone().set_style(Style::new().fg(color)))
|
|
};
|
|
let load_label = format!("{:.0}%", cpu.load_pct);
|
|
let lcolor = theme::load_color(cpu.load_pct);
|
|
let spark_text = if cpu.load_history.is_empty() {
|
|
" ".repeat(SPARK_WIDTH)
|
|
} else {
|
|
let mut padded: Vec<u8> = cpu.load_history.iter().copied().collect();
|
|
if padded.len() < SPARK_WIDTH {
|
|
let pad = SPARK_WIDTH - padded.len();
|
|
padded = std::iter::repeat_n(0u8, pad).chain(padded).collect();
|
|
}
|
|
padded_to_sparkline(&padded)
|
|
};
|
|
Row::new(vec![
|
|
Cell::from(
|
|
format!("{}{}", cpuid::core_type_label(cpu.core_type), cpu.id)
|
|
.set_style(theme::VALUE),
|
|
),
|
|
Cell::from(freq.set_style(freq_style)),
|
|
Cell::from(
|
|
pkg_power_w
|
|
.map(|w| format!("{w:.1}"))
|
|
.unwrap_or_else(|| match rapl_status {
|
|
s if s.contains("run as root") => "root".into(),
|
|
s if s.contains("unsupported") => "n/a".into(),
|
|
s if s.contains("sampling") => "...".into(),
|
|
_ => "n/a".into(),
|
|
})
|
|
.set_style(if pkg_power_w.is_some() {
|
|
theme::VALUE
|
|
} else {
|
|
theme::VALUE_OFF
|
|
}),
|
|
),
|
|
temp_cell,
|
|
Cell::from(pstate.set_style(theme::VALUE)),
|
|
Cell::from(cpu.state_label().set_style(theme::VALUE)),
|
|
flags_cell,
|
|
Cell::from(Line::from(vec![
|
|
spark_text.set_style(Style::new().fg(lcolor)),
|
|
format!(" {load_label}").set_style(Style::new().fg(lcolor).bold()),
|
|
])),
|
|
{
|
|
let irq_val = per_cpu_irqs.get(cpu.id as usize).copied();
|
|
Cell::from(
|
|
irq_val
|
|
.map(|v| format!("{v}"))
|
|
.unwrap_or_else(|| "—".into())
|
|
.set_style(if irq_val.is_some() {
|
|
theme::VALUE
|
|
} else {
|
|
theme::VALUE_OFF
|
|
}),
|
|
)
|
|
},
|
|
])
|
|
})
|
|
.collect();
|
|
let mut rows = rows;
|
|
if let Some(expanded_id) = expanded_cpu
|
|
&& let Some(cpu) = cpus.iter().find(|c| c.id == expanded_id)
|
|
{
|
|
let sub_style = theme::LABEL;
|
|
let active_style = Style::new().yellow().bold();
|
|
for (idx, pstate) in cpu.pstates.iter().enumerate() {
|
|
let is_current = cpu.current_idx == Some(idx);
|
|
let s = if is_current { active_style } else { sub_style };
|
|
let marker = if is_current { "▶" } else { "↳" };
|
|
let label = if is_current {
|
|
format!("{marker} P{idx} (current)")
|
|
} else {
|
|
format!("{marker} P{idx}")
|
|
};
|
|
let freq_mhz = pstate.freq_khz / 1000;
|
|
let power_w = pstate.power_mw as f64 / 1000.0;
|
|
rows.push(Row::new(vec![
|
|
label.set_style(s),
|
|
format!("{freq_mhz} MHz").set_style(s),
|
|
format!("{power_w:.1} W").set_style(s),
|
|
format!("0x{:02x}", (pstate.ctl >> 8) & 0x7f).set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
]));
|
|
}
|
|
if let Some(ref hwp) = cpu.hwp {
|
|
let s = sub_style;
|
|
rows.push(Row::new(vec![
|
|
" HWP caps".set_style(s),
|
|
format!(
|
|
"eff={} guar={} max={}",
|
|
hwp.efficient_perf, hwp.guaranteed_perf, hwp.max_perf
|
|
)
|
|
.set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
(if hwp.enabled { "ON" } else { "OFF" })
|
|
.to_string()
|
|
.set_style(if hwp.enabled {
|
|
Style::new().green()
|
|
} else {
|
|
theme::VALUE_OFF
|
|
}),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
]));
|
|
rows.push(Row::new(vec![
|
|
" HWP req".set_style(s),
|
|
format!(
|
|
"min={} max={} des={} EPP={} ({})",
|
|
hwp.min_request,
|
|
hwp.max_request,
|
|
hwp.desired_perf,
|
|
hwp.epp,
|
|
hwp.epp_label()
|
|
)
|
|
.set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
"".set_style(s),
|
|
]));
|
|
}
|
|
}
|
|
Table::new(
|
|
rows,
|
|
[
|
|
Constraint::Length(7),
|
|
Constraint::Length(10),
|
|
Constraint::Length(7),
|
|
Constraint::Length(8),
|
|
Constraint::Length(8),
|
|
Constraint::Length(8),
|
|
Constraint::Length(6),
|
|
Constraint::Length(SPARK_WIDTH as u16 + 6),
|
|
Constraint::Length(8),
|
|
],
|
|
)
|
|
.header(header)
|
|
.block(panel_border(focused, " Per-CPU ", theme))
|
|
.row_highlight_style(Style::new().bold().on_dark_gray())
|
|
.highlight_symbol("▶ ")
|
|
.column_spacing(1)
|
|
}
|
|
|
|
pub fn render_keybar<'a>(app: &'a App) -> Paragraph<'a> {
|
|
let gov = format!(" g:{}", app.cpufreq.active.as_str());
|
|
let interval = format!(" [/]:{}ms", app.poll_ms);
|
|
let tab_hints: &str = if app.current_tab == crate::app::TabId::Process {
|
|
" ↑↓:sel k:kill N:nice a:affinity l:files C:cmd o:sort i:invert F:filter y:tree Enter:detail"
|
|
} else {
|
|
" ↑↓:cpu p/P:±pstate m/M:min/max t:throttle r:refresh"
|
|
};
|
|
let mut parts = vec![
|
|
gov,
|
|
interval,
|
|
tab_hints.to_string(),
|
|
" T:tab +:theme ?:help e:expand f:freeze q:quit".to_string(),
|
|
];
|
|
if app.frozen {
|
|
parts.push(" [FROZEN]".to_string());
|
|
}
|
|
if app.expanded {
|
|
parts.push(" [EXPANDED]".to_string());
|
|
}
|
|
let line = parts.join("");
|
|
Paragraph::new(line)
|
|
.style(theme::VALUE_OFF)
|
|
.block(Block::default().borders(Borders::NONE))
|
|
}
|
|
|
|
pub const HELP_TEXT: &str = "\
|
|
redbear-power — interactive power/thermal monitor (TUI)
|
|
|
|
USAGE:
|
|
redbear-power [OPTIONS]
|
|
|
|
OPTIONS:
|
|
--once Render one frame and exit (smoke-test mode).
|
|
Useful for CI, scripting, and headless validation.
|
|
Output is a plain-text snapshot of the current TUI
|
|
state, written to stdout.
|
|
--dbus Publish org.redbear.Power on the session bus.
|
|
Requires redbear-sessiond to be running. If the
|
|
session bus is not reachable, the TUI continues
|
|
without D-Bus (a warning is printed to stderr).
|
|
--theme MODE Color theme: dark (default), light, high-contrast,
|
|
nord, gruvbox, solarized-dark.
|
|
Overrides [theme].mode in the config file.
|
|
--config PATH Load configuration from PATH instead of the
|
|
standard locations. The file must be valid TOML.
|
|
See the section TOML CONFIG below for schema.
|
|
--version Print version and exit.
|
|
-h, --help Print this help and exit.
|
|
|
|
TOML CONFIG:
|
|
Configuration files are searched, in order:
|
|
/etc/redbear-power.toml
|
|
~/.config/redbear-power.toml
|
|
|
|
Available sections and keys:
|
|
[display]
|
|
refresh_ms Default refresh interval in ms (min 50, max 60000)
|
|
show_simd_panel bool (default true)
|
|
show_cache_panel bool (default true)
|
|
show_hybrid_panel bool (default true)
|
|
show_pkg_flags_panel bool (default true)
|
|
spark_width int (default 20)
|
|
load_history_len int (default 30)
|
|
dbus_name string (default \"org.redbear.Power\")
|
|
|
|
[theme]
|
|
mode \"dark\" | \"light\" | \"high-contrast\" | \"nord\"
|
|
| \"gruvbox\" | \"solarized-dark\"
|
|
focused_border Color name (e.g. \"yellow\", \"white\", \"cyan\")
|
|
dim_border Color name
|
|
|
|
[keybindings]
|
|
quit Key name (default \"q\")
|
|
cycle_governor Key name (default \"g\")
|
|
refresh_now Key name (default \"r\")
|
|
toggle_help Key name (default \"?\")
|
|
snapshot Key name (default \"c\")
|
|
benchmark_start Key name (default \"b\")
|
|
benchmark_stop Key name (default \"B\")
|
|
|
|
[benchmark]
|
|
default_duration_s int (default 30)
|
|
auto_stop_temp_c int or null (default 95)
|
|
|
|
INTERACTIVE CONTROLS:
|
|
|
|
──────── TABS ────────
|
|
[1] Per-CPU [2] System [3] Info
|
|
[4] Motherboard [5] Battery [6] Sensors
|
|
[7] Network [8] Storage [9] Process
|
|
[T] cycle to next tab
|
|
|
|
──────── POWER & CPU ────────
|
|
|
|
[g] cycle governor (Performance / Ondemand / Powersave)
|
|
[p/P] step selected CPU P-state down / up
|
|
[m/M] force selected CPU to min / max P-state
|
|
[t] toggle throttle mode (Auto / User / ForcedMin)
|
|
[Up] select previous CPU row
|
|
[Down] select next CPU row
|
|
[PgUp] page up (8 rows)
|
|
[PgDn] page down (8 rows)
|
|
[r] force refresh now
|
|
[c] dump current frame to /tmp/redbear-power-snapshot.txt
|
|
[[] decrease refresh interval (250 / 500 / 1000 / 2000 ms)
|
|
[]] increase refresh interval
|
|
[/] type a custom refresh interval (50-60000 ms), Enter to confirm
|
|
[b] start 30s benchmark on all cores (thermal load test)
|
|
[B] stop the running benchmark
|
|
[n] cycle benchmark kind (prime sieve / FFT / AES)
|
|
[s] toggle single-core vs all-cores benchmark mode
|
|
[Tab] cycle keyboard focus (header / table / controls)
|
|
[Enter] toggle P-state expansion for selected CPU
|
|
[e] expand current tab to full-screen (toggle)
|
|
[f] freeze / unfreeze data updates
|
|
|
|
──────── PROCESS MANAGEMENT ────────
|
|
|
|
[k] kill selected process (Process tab only)
|
|
[N] edit nice value of selected process (Process tab)
|
|
[a] edit CPU affinity of selected process (Process tab)
|
|
[l] list open file descriptors of selected process (Process tab)
|
|
[C] toggle full command line vs comm name (Process tab)
|
|
[y] toggle process tree view (Process tab)
|
|
[F] filter processes by name (Process tab)
|
|
[o] cycle process sort mode
|
|
[i] invert sort direction
|
|
[Space] fold/unfold process subtree (tree mode)
|
|
|
|
──────── NAVIGATION ────────
|
|
|
|
[Home] jump to top of list
|
|
[End] jump to bottom of list
|
|
[G] jump to bottom (vim-style)
|
|
[j/k] move selection down/up (vim-style)
|
|
[?] toggle this help overlay
|
|
[+] cycle color theme (6 themes)
|
|
|
|
MOUSE:
|
|
Wheel scroll the per-CPU selection up/down (over the table panel)
|
|
Left select a CPU row (table), cycle governor (header or controls)
|
|
Right toggle P-state expansion (table), toggle throttle (header),
|
|
toggle throttle (controls)
|
|
Middle toggle P-state expansion (table), toggle throttle (controls)
|
|
[q] quit
|
|
|
|
──────── NOTES ────────
|
|
- MSR writes (g, p, P, m, M, t) require CAP_SYS_MSR; the binary is
|
|
intended to run as root (euid 0).
|
|
- When MSR or cpufreq schemes are absent (e.g., QEMU without MSR),
|
|
the TUI degrades to placeholder values; mutations are disabled.
|
|
- Braille time-series graphs appear in System, Network, and Storage
|
|
tabs: CPU Load %, Temp °C, Pkg Power W, Net/Storage throughput.
|
|
- Press 'e' to expand any tab to full-screen with graph views.
|
|
- Press 'f' to freeze data — scroll and inspect without updates.
|
|
- The benchmark runs one worker thread per logical core and
|
|
stresses the CPU to observe thermal response.
|
|
- Config file (~/.config/redbear-power.toml) auto-loaded on startup.
|
|
- Use --theme light|high-contrast for alternative color schemes.
|
|
- MSR reads are cached per-CPU; cache clears every ~30s.
|
|
- Snapshot (c key) saves Per-CPU + System + Network + Storage
|
|
+ graphs to /tmp/redbear-power-snapshot.txt.";
|
|
|
|
pub fn render_help<'a>(help_scroll: usize, theme: &Theme) -> Paragraph<'a> {
|
|
let lines: Vec<&str> = HELP_TEXT.lines().collect();
|
|
let total = lines.len();
|
|
let start = help_scroll.min(total);
|
|
let visible: String = lines[start..].join("\n");
|
|
let pct = if total > 0 { start * 100 / total } else { 0 };
|
|
Paragraph::new(visible)
|
|
.style(theme::VALUE)
|
|
.wrap(Wrap { trim: true })
|
|
.scroll((help_scroll as u16, 0))
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(Line::styled(
|
|
format!(" Help ({}%, j/k to scroll) ", pct),
|
|
theme::LABEL_BOLD,
|
|
))
|
|
.border_style(theme.border_focused),
|
|
)
|
|
}
|
|
|
|
/// Render the full TUI into a fixed-size buffer and return the
|
|
/// contents as a single string. Used by both `--once` stdout output
|
|
/// and the interactive snapshot (c key).
|
|
pub fn snapshot(app: &App, width: u16, height: u16) -> String {
|
|
let backend = TestBackend::new(width, height);
|
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
|
let mut state = app.table_state;
|
|
terminal
|
|
.draw(|f| {
|
|
let [header_area, table_area, controls_area] = f.area().layout(&Layout::vertical([
|
|
Constraint::Length(HEADER_LINES),
|
|
Constraint::Min(6),
|
|
Constraint::Length(KEYBAR_LINES),
|
|
]));
|
|
f.render_widget(render_header(app, true), header_area);
|
|
f.render_stateful_widget(
|
|
render_cpu_table(
|
|
&app.cpus,
|
|
app.expanded_cpu,
|
|
true,
|
|
app.pkg_power_w,
|
|
&app.rapl_status,
|
|
&app.sched_stats.per_cpu_irqs,
|
|
&app.theme,
|
|
),
|
|
table_area,
|
|
&mut state,
|
|
);
|
|
f.render_widget(render_keybar(app), controls_area);
|
|
})
|
|
.expect("draw");
|
|
buffer_to_string(terminal.backend().buffer())
|
|
}
|
|
|
|
pub fn buffer_to_string(buf: &ratatui::buffer::Buffer) -> String {
|
|
let w = buf.area.width;
|
|
let h = buf.area.height;
|
|
let mut out = String::with_capacity((w as usize + 8) * h as usize);
|
|
for y in 0..h {
|
|
let mut line = String::with_capacity(w as usize + 8);
|
|
for x in 0..w {
|
|
let cell = &buf[(x, y)];
|
|
line.push_str(cell.symbol());
|
|
}
|
|
out.push_str(line.trim_end());
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn render_once(app: &App) -> io::Result<()> {
|
|
print!("{}", snapshot(app, 140, 50));
|
|
// Also dump the System panel as a second snapshot for verification.
|
|
eprintln!("--- System panel (verifies v1.4 memory + OS info) ---");
|
|
let sys_para = render_system_panel(app, false);
|
|
let backend = TestBackend::new(120, 30);
|
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
|
terminal
|
|
.draw(|f| {
|
|
f.render_widget(sys_para, f.area());
|
|
})
|
|
.expect("draw");
|
|
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
|
eprintln!("--- Motherboard panel (verifies v1.5 DMI/SMBIOS) ---");
|
|
let mb_para = render_motherboard_panel(app, false);
|
|
let backend = TestBackend::new(120, 40);
|
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
|
terminal
|
|
.draw(|f| {
|
|
f.render_widget(mb_para, f.area());
|
|
})
|
|
.expect("draw");
|
|
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
|
eprintln!("--- Battery panel (verifies v1.6 power_supply) ---");
|
|
let bat_para = render_battery_panel(app, false);
|
|
let backend = TestBackend::new(120, 30);
|
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
|
terminal
|
|
.draw(|f| {
|
|
f.render_widget(bat_para, f.area());
|
|
})
|
|
.expect("draw");
|
|
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
|
eprintln!("--- Sensors panel (verifies v1.9 hwmon) ---");
|
|
let sen_para = render_sensor_panel(app, false);
|
|
let backend = TestBackend::new(120, 50);
|
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
|
terminal
|
|
.draw(|f| {
|
|
f.render_widget(sen_para, f.area());
|
|
})
|
|
.expect("draw");
|
|
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
|
eprintln!("--- Network panel (verifies v1.11 sysfs) ---");
|
|
let net_para = render_network_panel(app, false);
|
|
let backend = TestBackend::new(120, 60);
|
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
|
terminal
|
|
.draw(|f| {
|
|
f.render_widget(net_para, f.area());
|
|
})
|
|
.expect("draw");
|
|
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
|
eprintln!("--- Storage panel (verifies v1.12 sysfs) ---");
|
|
let sto_para = render_storage_panel(app, false);
|
|
let backend = TestBackend::new(120, 40);
|
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
|
terminal
|
|
.draw(|f| {
|
|
f.render_widget(sto_para, f.area());
|
|
})
|
|
.expect("draw");
|
|
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
|
eprintln!("--- Process panel (verifies v1.13 procfs) ---");
|
|
let proc_para = render_process_panel(app, false);
|
|
let backend = TestBackend::new(120, 60);
|
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
|
terminal
|
|
.draw(|f| {
|
|
f.render_widget(proc_para, f.area());
|
|
})
|
|
.expect("draw");
|
|
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
|
// Graph snapshots (v1.44: braille time-series graphs).
|
|
render_graph_snapshot(
|
|
app,
|
|
" CPU Load % ",
|
|
app.cpu_graph_history.as_slice(),
|
|
100.0,
|
|
ratatui::style::Color::Cyan,
|
|
"100",
|
|
" 0",
|
|
);
|
|
let max_t = app.temp_graph_history.display_max().max(50.0);
|
|
render_graph_snapshot(
|
|
app,
|
|
" Temp °C ",
|
|
app.temp_graph_history.as_slice(),
|
|
max_t,
|
|
ratatui::style::Color::Red,
|
|
&format!("{:.0}", max_t),
|
|
"0",
|
|
);
|
|
let max_p = app.power_graph_history.display_max().max(20.0);
|
|
render_graph_snapshot(
|
|
app,
|
|
" Pkg Power W ",
|
|
app.power_graph_history.as_slice(),
|
|
max_p,
|
|
ratatui::style::Color::Yellow,
|
|
&format!("{:.0}", max_p),
|
|
"0",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn render_graph_snapshot(
|
|
app: &App,
|
|
title: &str,
|
|
values: &[f64],
|
|
max_val: f64,
|
|
color: ratatui::style::Color,
|
|
top_label: &str,
|
|
bot_label: &str,
|
|
) {
|
|
eprintln!("--- Graph:{title} ---");
|
|
let backend = TestBackend::new(80, GRAPH_HEIGHT);
|
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
|
let graph = BrailleGraph {
|
|
values,
|
|
max_value: max_val,
|
|
color,
|
|
title: title.to_string(),
|
|
focused: false,
|
|
x_labels: None,
|
|
y_labels: Some((top_label.to_string(), bot_label.to_string())),
|
|
theme: &app.theme,
|
|
};
|
|
terminal
|
|
.draw(|f| f.render_widget(graph, f.area()))
|
|
.expect("draw");
|
|
print!("{}", buffer_to_string(terminal.backend().buffer()));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::process::ProcessInfo;
|
|
|
|
fn make_proc(pid: u32, ppid: u32) -> ProcessInfo {
|
|
ProcessInfo {
|
|
pid,
|
|
ppid,
|
|
comm: format!("p{pid}"),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn empty_folded() -> std::collections::BTreeSet<u32> {
|
|
std::collections::BTreeSet::new()
|
|
}
|
|
|
|
#[test]
|
|
fn tree_prefix_root_has_no_ancestor_bar() {
|
|
// Single root, no children -> no connector, no bar.
|
|
let all = vec![make_proc(1, 0)];
|
|
let s = tree_prefix_for_test(1, 0, &all, &empty_folded());
|
|
// No ancestors -> empty prefix.
|
|
assert!(
|
|
s.is_empty() || s == " ",
|
|
"root prefix is empty or fold marker"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tree_prefix_child_uses_connector() {
|
|
// 1 -> 2: child of 1. Last child of 1 in this list.
|
|
// Leaf (no children) so fold marker is " ".
|
|
let all = vec![make_proc(1, 0), make_proc(2, 1)];
|
|
let s = tree_prefix_for_test(2, 1, &all, &empty_folded());
|
|
// Depth 1, no continuation (2 is the last in the list).
|
|
// Connector is "└─ " (last child), bar is empty.
|
|
assert_eq!(
|
|
s, " └─ ",
|
|
"leaf child of single child: expected ' └─ ' (bar=empty, connector=last, fold=leaf), got '{s}'"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tree_prefix_two_level_continues_bar() {
|
|
// 1 -> {2 -> 3, 4}. pid=2 is NOT the last child of 1
|
|
// (pid=4 follows), so its bar at depth 0 should
|
|
// continue: "│ └─ " (the bar is at depth 0; depth 1
|
|
// is the row itself's connector). pid=2 has a child
|
|
// (pid=3) so the fold marker is "▼ " (expanded).
|
|
let all = vec![
|
|
make_proc(1, 0),
|
|
make_proc(2, 1),
|
|
make_proc(3, 2),
|
|
make_proc(4, 1),
|
|
];
|
|
let s = tree_prefix_for_test(2, 1, &all, &empty_folded());
|
|
// bar="│ " + connector="└─ " + fold="▼ "
|
|
assert_eq!(s, "│ └─ ▼ ", "got '{s}'");
|
|
}
|
|
|
|
#[test]
|
|
fn tree_prefix_three_level_continues_bar_at_each_depth() {
|
|
// 1 -> {2 -> {3, 4}, 5}
|
|
// pid=3 is depth 2 (parent=2, grandparent=1, great-grandparent=root).
|
|
// Its bar at depth 0 (above pid=3) is for ancestor 2.
|
|
// is 2 still active in the next row? Next row is pid=4,
|
|
// ppid=2. So yes -> "│ ".
|
|
// Its bar at depth 1 (above pid=3) is for ancestor 1.
|
|
// is 1 still active in the next row? Next row is pid=4,
|
|
// ppid=2; walk: 4's ppid=2, 2's ppid=1. So 1 is in
|
|
// the chain -> "│ ".
|
|
let all = vec![
|
|
make_proc(1, 0),
|
|
make_proc(2, 1),
|
|
make_proc(3, 2),
|
|
make_proc(4, 2),
|
|
make_proc(5, 1),
|
|
];
|
|
let s = tree_prefix_for_test(3, 2, &all, &empty_folded());
|
|
// Expected: "│ │ ├─ " (2 levels of "│ ", then the
|
|
// connector for the row itself).
|
|
assert!(
|
|
s.starts_with("│ │ ├─ "),
|
|
"expected double-continue bar, got '{s}'"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tree_prefix_bar_ends_when_no_more_siblings() {
|
|
// 1 -> 2 (the only child). pid=2's bar at depth 0 is
|
|
// for ancestor 1. Is 1 active in the next row? No
|
|
// (there's no next row). So bar is " " and the
|
|
// connector is "└─ " (last child). Leaf so fold
|
|
// marker is " ".
|
|
let all = vec![make_proc(1, 0), make_proc(2, 1)];
|
|
let s = tree_prefix_for_test(2, 1, &all, &empty_folded());
|
|
// bar=" " + connector="└─ " + fold=" "
|
|
assert_eq!(s, " └─ ", "single-child tree leaf: got '{s}'");
|
|
}
|
|
|
|
#[test]
|
|
fn tree_prefix_fold_marker_shown_when_has_children() {
|
|
// 1 -> 2. Pid 1 has a child, so the fold marker is "▼ "
|
|
// (expanded). The full prefix for pid 1 (depth 0, root)
|
|
// is just "▼ " (no connector, fold marker).
|
|
let all = vec![make_proc(1, 0), make_proc(2, 1)];
|
|
let s = tree_prefix_for_test(1, 0, &all, &empty_folded());
|
|
assert!(
|
|
s.starts_with("▼ "),
|
|
"expanded root should show ▼, got '{s}'"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tree_prefix_fold_marker_collapsed() {
|
|
// 1 -> 2, with pid 1 in the folded set.
|
|
let all = vec![make_proc(1, 0), make_proc(2, 1)];
|
|
let mut folded = std::collections::BTreeSet::new();
|
|
folded.insert(1);
|
|
let s = tree_prefix_for_test(1, 0, &all, &folded);
|
|
assert!(s.starts_with("▶ "), "folded root should show ▶, got '{s}'");
|
|
}
|
|
|
|
#[test]
|
|
fn tree_prefix_leaf_has_no_fold_marker() {
|
|
// 1 -> 2. Pid 2 has no children, so no fold marker.
|
|
let all = vec![make_proc(1, 0), make_proc(2, 1)];
|
|
let s = tree_prefix_for_test(2, 1, &all, &empty_folded());
|
|
// No "▶" or "▼" should appear.
|
|
assert!(!s.contains('▶'), "leaf should not show ▶, got '{s}'");
|
|
assert!(!s.contains('▼'), "leaf should not show ▼, got '{s}'");
|
|
}
|
|
}
|