9cd0a25906
v1.37 audit found 2 new bugs + recommended 5 v1.38 htop/btop-parity features. This release fixes both bugs and ships all 5 features. v1.37-0 (HIGH): set_tab() clears last_clicked_cpu The v1.37 re-click-to-expand feature set last_clicked_cpu on click but never reset it on tab switch. A user who clicked Per-CPU row 5, switched tabs, and came back would unexpectedly toggle expand. Fix: add App::set_tab(TabId) helper that resets both last_clicked_cpu and expanded_cpu, and route all 9 tab keys (1-9) + T through it. v1.37-1 (MEDIUM): mouse click respects filter The Process tab mouse click set process_cursor from the raw screen row, ignoring the active filter. With a filter active, the cursor highlight wouldn't align with the click, and right-click opened the wrong PID detail. Fix: new App::process_cursor_at_y(y, first_data_y) that walks the post-filter visible list and clamps to the last visible row. Wired into both left-click and right-click in handle_mouse. v1.38-2: SortDir + i key for direction toggle htop parity for the 'i' key. New App.sort_ascending: bool. The SortMode enum gets a new sort_ascending(procs, true) method (the existing sort() now delegates to sort_ascending(procs, false) for backward compat). On each refresh, if sort_ascending is true, the processes are re-sorted after the default descending pass. Press 'i' to flip; the status flash includes the current direction. v1.38-3: cmdline + io_priority in PID detail htop parity. New PidDetail.cmdline reads /proc/[pid]/cmdline, replaces NUL with space, strips trailing NULs. Rendered in the PID detail popup (truncated to 120 chars). New PidDetail.io_priority reads /proc/[pid]/stat field 47. Both are tolerant of missing files. v1.38-4: per-disk I/O throughput sparkline btop parity. New App.disk_history: BTreeMap<String, VecDeque<u8>> keyed by disk name. Mirrors the io_history pattern: each storage refresh collects raw kbps samples, normalizes per-disk against its own max, writes u8 to the public history. Rendered in the Storage tab as a 12-char sparkline next to each disk name. Reaps disks that have disappeared. Test count 140 -> 149 (+9): - set_tab_clears_last_clicked_cpu_and_expanded_cpu - process_cursor_at_y_respects_filter - process_cursor_at_y_clamps_to_last_visible - sort_ascending_flips_rss_order - read_cmdline_replaces_nul_with_space - read_cmdline_handles_missing_pid - read_io_priority_handles_self - read_io_priority_handles_missing_pid - update_disk_history_reaps_exited_disks Redox stripped binary: 4,348,776 bytes (+106 KiB from v1.37). Compile warnings: 56 (unchanged; all pre-existing).
1802 lines
70 KiB
Rust
1802 lines
70 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};
|
|
use ratatui::style::{Style, Styled, Stylize};
|
|
use ratatui::text::Line;
|
|
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs, Wrap};
|
|
use ratatui::{Frame, Terminal};
|
|
|
|
use crate::app::{App, CpuRow, TabId, SPARK_WIDTH, ThrottleMode};
|
|
use crate::cpuid;
|
|
use crate::theme;
|
|
|
|
pub const HEADER_LINES: u16 = 8;
|
|
pub const CONTROLS_LINES: u16 = 25;
|
|
pub const TAB_BAR_LINES: u16 = 1;
|
|
|
|
/// 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(focused: bool, title: &str) -> Block<'_> {
|
|
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))
|
|
}
|
|
|
|
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 pkg_flags: String = app.pkg_thermal.short_label();
|
|
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.governor.name().set_style(theme::HEADER_GOVERNOR),
|
|
" ".into(),
|
|
"Throttle: ".set_style(theme::LABEL),
|
|
match app.throttle {
|
|
ThrottleMode::Auto => "AUTO".set_style(theme::HEADER_THROTTLE_AUTO).into(),
|
|
ThrottleMode::User => "USER".set_style(theme::HEADER_THROTTLE_USER).into(),
|
|
ThrottleMode::ForcedMin => "FORCED MIN".set_style(theme::HEADER_THROTTLE_FORCED).into(),
|
|
},
|
|
]),
|
|
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(),
|
|
"PkgFlags: ".set_style(theme::LABEL),
|
|
pkg_flags.clone().set_style(theme::STATUS_WARN),
|
|
" ".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 "))
|
|
.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
|
|
}
|
|
Line::from(vec![
|
|
label.set_style(theme::LABEL),
|
|
format!("[{}] ", bar).set_style(color),
|
|
format!("{:5.1}% ", percent).set_style(theme::VALUE),
|
|
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 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| Line::from(t.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),
|
|
]));
|
|
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) },
|
|
]));
|
|
|
|
// 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));
|
|
}
|
|
}
|
|
|
|
lines.push(Line::from(vec![
|
|
"Benchmark: ".set_style(theme::LABEL),
|
|
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 "))
|
|
.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 "))
|
|
.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 "))
|
|
.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 "))
|
|
.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 "))
|
|
.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 "))
|
|
.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 "))
|
|
.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 "))
|
|
.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 {
|
|
if !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 {
|
|
if 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 "))
|
|
.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 "))
|
|
.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 "))
|
|
.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 "))
|
|
.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)
|
|
};
|
|
lines.push(Line::from(format!(
|
|
"Showing top {} of {} process(es); total RSS: {}; sort: {}{}{} (press 'o' to cycle, 'T' for tree, '/' to filter)",
|
|
proc.count(),
|
|
proc.total_count,
|
|
crate::process::ProcessInfo::format_memory_kb(proc.total_memory_kb),
|
|
app.process_sort.name(),
|
|
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 PRIO NI THR CPU% IO RATE {:<11} IO-RATE CPU% RSS 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 comm_truncated: String = 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 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),
|
|
};
|
|
// 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 {
|
|
theme::CURSOR
|
|
} else {
|
|
theme::VALUE
|
|
};
|
|
lines.push(Line::from(format!(
|
|
" {}{:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {:<12} {:<6} {:<6} {}",
|
|
prefix,
|
|
p.pid,
|
|
p.state,
|
|
p.priority,
|
|
p.nice,
|
|
p.num_threads,
|
|
format!("{:.1}", p.cpu_pct),
|
|
io_str,
|
|
rate_str,
|
|
mem_str,
|
|
io_spark,
|
|
cpu_spark,
|
|
rss_spark,
|
|
comm_truncated,
|
|
).set_style(row_style)));
|
|
}
|
|
Paragraph::new(lines)
|
|
.block(panel_border(focused, " Process "))
|
|
.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.map_or(true, |np| np != 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.map_or(false, |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) -> 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)));
|
|
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)));
|
|
Paragraph::new(lines)
|
|
.block(panel_border(true, " PID Detail "))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
pub fn render_cpu_table<'a>(
|
|
cpus: &'a [CpuRow],
|
|
expanded_cpu: Option<u32>,
|
|
focused: bool,
|
|
) -> 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),
|
|
])
|
|
.height(1);
|
|
let rows: Vec<Row> = cpus
|
|
.iter()
|
|
.map(|cpu| {
|
|
let freq = if cpu.freq_khz > 0 {
|
|
format!("{}", cpu.freq_khz / 1000)
|
|
} 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);
|
|
Cell::from(Line::from(vec![
|
|
format!("{t:>3} ").set_style(Style::new().fg(color)),
|
|
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 pkgw_cell: Cell = match cpu.current_power_mw {
|
|
Some(w) => Cell::from(format!("{:.1}", w as f64 / 1000.0).set_style(theme::VALUE)),
|
|
None => Cell::from("n/a".set_style(theme::VALUE_OFF)),
|
|
};
|
|
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(0u8).take(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(theme::VALUE)),
|
|
pkgw_cell,
|
|
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()),
|
|
])),
|
|
])
|
|
})
|
|
.collect();
|
|
let mut rows = rows;
|
|
if let Some(expanded_id) = expanded_cpu {
|
|
if 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),
|
|
]));
|
|
}
|
|
}
|
|
}
|
|
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),
|
|
],
|
|
)
|
|
.header(header)
|
|
.block(panel_border(focused, " Per-CPU "))
|
|
.row_highlight_style(Style::new().bold().on_dark_gray())
|
|
.highlight_symbol("▶ ")
|
|
.column_spacing(1)
|
|
}
|
|
|
|
pub fn render_controls<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
|
|
let mut lines = vec![
|
|
Line::from("Controls".set_style(theme::LABEL_BOLD)),
|
|
Line::from(""),
|
|
Line::from(vec![" [g] ".yellow(), "cycle governor".into()]),
|
|
Line::from(vec![" [p] ".yellow(), "P-state -1 (slower, cooler)".into()]),
|
|
Line::from(vec![" [P] ".yellow(), "P-state +1 (faster, hotter)".into()]),
|
|
Line::from(vec![" [m] ".yellow(), "force min P-state (max relief)".into()]),
|
|
Line::from(vec![" [M] ".yellow(), "force max P-state (max perf)".into()]),
|
|
Line::from(vec![" [t] ".yellow(), "toggle throttle mode".into()]),
|
|
Line::from(vec![" [↑/↓]".yellow(), " select CPU row".into()]),
|
|
Line::from(vec![" [PgUp/PgDn]".yellow(), " page up/down (8 rows)".into()]),
|
|
Line::from(vec![" [r] ".yellow(), "force refresh now".into()]),
|
|
Line::from(vec![
|
|
" [c] ".yellow(),
|
|
"snapshot → /tmp/redbear-power-snapshot.txt".into(),
|
|
]),
|
|
Line::from(vec![
|
|
" [[/]] ".yellow(),
|
|
"refresh interval (250 / 500 / 1000 / 2000 ms)".into(),
|
|
]),
|
|
Line::from(vec![
|
|
" [/] ".yellow(),
|
|
"type a custom refresh interval (50-60000 ms)".into(),
|
|
]),
|
|
Line::from(vec![
|
|
" [b/B] ".yellow(),
|
|
"start/stop 30s benchmark (prime sieve / FFT / AES)".into(),
|
|
]),
|
|
Line::from(vec![
|
|
" [n] ".yellow(),
|
|
"cycle benchmark kind (sieve → FFT → AES → sieve)".into(),
|
|
]),
|
|
Line::from(vec![
|
|
" [s] ".yellow(),
|
|
"toggle single-core vs all-cores benchmark mode".into(),
|
|
]),
|
|
Line::from(vec![
|
|
" [Tab] ".yellow(),
|
|
"cycle keyboard focus (header / table / controls)".into(),
|
|
]),
|
|
Line::from(vec![
|
|
" [Enter] ".yellow(),
|
|
"toggle P-state expansion for selected CPU".into(),
|
|
]),
|
|
Line::from(vec![
|
|
" [?] ".yellow(),
|
|
"toggle this help overlay".into(),
|
|
]),
|
|
Line::from(vec![
|
|
" Mouse: ".yellow(),
|
|
"wheel=scroll L=select R=expand".into(),
|
|
]),
|
|
Line::from(vec![" [q] ".yellow(), "quit".into()]),
|
|
];
|
|
if !app.bench_line.is_empty() {
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from(app.bench_line.as_str().set_style(theme::STATUS_WARN)));
|
|
}
|
|
if let Some(buf) = app.interval_input.as_ref() {
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from(
|
|
format!(" refresh (ms): {buf}█").set_style(theme::LABEL_BOLD),
|
|
));
|
|
}
|
|
if let Some(msg) = app.status_text() {
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from(
|
|
format!(" → {msg}").set_style(theme::STATUS_OK),
|
|
));
|
|
}
|
|
Paragraph::new(lines)
|
|
.block(panel_border(focused, " Controls "))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
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).
|
|
--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\"
|
|
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:
|
|
[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
|
|
[?] toggle this help overlay
|
|
|
|
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.
|
|
- The benchmark runs one worker thread per logical core and
|
|
stresses the CPU to observe thermal response. Use it to validate
|
|
thermald/cpufreqd behavior under sustained load.
|
|
";
|
|
|
|
pub fn render_help() -> Paragraph<'static> {
|
|
Paragraph::new(HELP_TEXT)
|
|
.block(Block::default().borders(Borders::ALL).title(" Help "))
|
|
.wrap(Wrap { trim: true })
|
|
}
|
|
|
|
/// 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(CONTROLS_LINES),
|
|
]),
|
|
);
|
|
f.render_widget(render_header(app, true), header_area);
|
|
f.render_stateful_widget(
|
|
render_cpu_table(&app.cpus, app.expanded_cpu, true),
|
|
table_area,
|
|
&mut state,
|
|
);
|
|
f.render_widget(render_controls(app, true), 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()));
|
|
Ok(())
|
|
}
|
|
|
|
#[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}'");
|
|
}
|
|
}
|