Files
RedBear-OS/local/recipes/system/redbear-power/source/src/render.rs
T
vasilito df3021575e redbear-power: v1.28 virtual size sort (activates vsize_kb)
Closes the v1.23-deferred 'vsize_kb' future-use. Adds SortMode::VSize
that sorts by virtual size, and swaps the Process panel's MEM
column to show VSZ (instead of RSS) when that sort is active.

The column-swap pattern (the column being sorted IS the column
being shown) keeps the panel at 10 columns instead of growing
to 11. htop uses the same pattern: when you sort by a field,
that field's column expands to show the data. No new column
means no terminal-width pressure at 1280x720 framebuffer.

The 'ppid' field's #[allow(dead_code)] is also removed (now
actively read by sort_tree + tree_prefix from v1.27). Both
fields now have proper doc comments explaining their use
(vs the v1.23 'reserved for future use' placeholder).

VSZ is a virtual address-space metric (mmap'd libraries, heap,
stack, reserved-but-uncommitted) and is often much larger than
RSS. Useful for 'who is using the most address space' but NOT
for 'who is using the most physical memory' (use RSS for that).
This caveat is now documented in the field's doc comment to
prevent operator confusion.

Test count 105 -> 107 (+2):
- sort_by_vsize_descending (basic)
- sort_by_vsize_uses_vsize_not_rss (contract test: huge VSZ +
  tiny RSS sorts above tiny VSZ + huge RSS; catches any
  'optimization' that uses the larger of the two fields)
- sort_cycle and io_name_is_io updated for VSize

Redox stripped binary: 4,189,032 bytes (+4 KiB from v1.27).
Compile warnings: 55 (no net change; the 2 removed
#[allow(dead_code)] annotations cancel against 2 new
warnings that did not exist before because the fields were
only accessed from the parse path).

Docs: local/docs/redbear-power-improvement-plan.md \xC2\xA752
2026-06-21 01:46:11 +03:00

1508 lines
59 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
}
/// 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)));
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 = if app.process_sort == crate::process::SortMode::VSize {
"VSZ"
} else {
"RSS"
};
let header_str = format!(
" PID STATE PRIO NI THR CPU% IO RATE {:<11} COMM",
mem_header
);
lines.push(Line::from(vec![
header_str.set_style(theme::LABEL),
]));
for p in &proc.processes {
if !app.process_filter.is_empty()
&& !p.comm.to_lowercase().contains(&app.process_filter.to_lowercase())
{
continue;
}
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 = if app.process_sort == crate::process::SortMode::VSize {
crate::process::ProcessInfo::format_memory_kb(p.vsize_kb)
} else {
crate::process::ProcessInfo::format_memory_kb(p.rss_kb)
};
let prefix = if app.process_tree {
tree_prefix(p.pid, p.ppid, &proc.processes)
} else {
String::new()
};
lines.push(Line::from(format!(
" {}{:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {}",
prefix,
p.pid,
p.state,
p.priority,
p.nice,
p.num_threads,
format!("{:.1}", p.cpu_pct),
io_str,
rate_str,
mem_str,
comm_truncated,
).set_style(theme::VALUE)));
}
Paragraph::new(lines)
.block(panel_border(focused, " Process "))
.wrap(Wrap { trim: true })
}
/// Build a tree prefix string for a process: `└─ ` (last child),
/// `├─ ` (non-last child), or empty (root). Walks the ppid chain to
/// determine depth and uses the next row in `all` to decide whether
/// this row is the last sibling of its parent.
///
/// O(N) per call, O(N^2) worst case for the full render. Fine for
/// the truncated top-50 list.
fn tree_prefix(pid: u32, ppid: u32, all: &[crate::process::ProcessInfo]) -> 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. Stop when we hit a root (ppid==0 or
// ppid not in list) or a cycle (safety bound of 64 hops).
let mut depth: usize = 0;
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;
}
cur = p.ppid;
depth += 1;
}
if depth == 0 {
return String::new();
}
// "Last child" iff the next row in the list has a different
// ppid (or there is no next row). Filter is applied at render
// time but the sort_tree output is the source of truth for
// sibling order, so this approximation is exact for unfiltered
// rows.
let my_index = match all.iter().position(|p| p.pid == pid) {
Some(i) => i,
None => return String::new(),
};
let is_last = match all.get(my_index + 1) {
Some(next) => next.ppid != ppid,
None => true,
};
let indent = " ".repeat(depth - 1);
let connector = if is_last { "└─ " } else { "├─ " };
format!("{}{}", indent, connector)
}
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)));
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(())
}