Files
RedBear-OS/local/recipes/system/redbear-power/source/src/render.rs
T
vasilito 6fa762d9e1 redbear-power: auto-fix 84 clippy warnings (100→16)
Mechanical fixes: collapsible-if, is_multiple_of, sort_by_key,
needless borrows, useless format!, manual checked division,
push-after-creation, and other style lints. Zero behavioral
changes. Remaining 16 warnings are pre-existing intentional
patterns (identical if-blocks from match arms, &mut Vec params).
2026-07-07 04:58:45 +03:00

2559 lines
93 KiB
Rust

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