Files
RedBear-OS/local/recipes/system/redbear-power/source/src/render.rs
T
vasilito 9cd0a25906 redbear-power: v1.38 audit fixes + htop/btop parity
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).
2026-06-21 09:50:31 +03:00

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}'");
}
}