redbear-power: v1.2 — config file, AMD CCD, tab system, D-Bus methods

Completes the remaining plan §24 deferred items:

- Config file (TOML): new config.rs module loaded from
  /etc/redbear-power.toml and ~/.config/redbear-power.toml (with
  --config <path> override). Sections: display, theme, keybindings,
  benchmark. --help documents the full schema.

- AMD Zen CCD topology: cpuid.rs detect_hybrid now parses leaf
  0x8000001E NC field (cores per CCX) and Zen 4+ leaf 0x80000026
  (CCD count + cores per CCD). Linux host with 24 AMD cores now
  shows CCD0..CCD5 grouping instead of all-Unknown.

- Multi-view tab system: Per-CPU / System / Info tabs via ratatui
  Tabs widget. Hotkeys 1/2/3 jump directly; T cycles. System tab
  shows aggregate CPU stats (avg freq, max temp, total pkg power,
  aggregate flags, bench status). Info tab shows detailed CPU
  identification (family/model/stepping hex, full flags list,
  per-level cache hierarchy with KB+way+line size). New
  render_tab_bar / render_system_panel / render_info_panel render
  functions in render.rs. TabId enum added to app.rs.

- D-Bus methods: added CycleGovernor, SetGovernor(name),
  ToggleThrottle, ForceMinPstate, ForceMaxPstate, SetPstate(target)
  methods to org.redbear.Power. PowerCommand enum + command
  channel back to main thread for MSR-bound actions. New App
  methods set_governor(Governor) and set_selected_pstate(i32)
  enable D-Bus clients to set governor/P-state without sending
  keystrokes.

- Mouse sub-panel navigation: refined hit-test so left-click on
  header/controls cycles governor, right-click toggles throttle,
  middle-click on table expands P-state. Header now has two
  distinct actions (governor + throttle) reachable via different
  mouse buttons without per-label x hit-test.

New dependencies in Cargo.toml: toml = "0.8", dirs = "5",
serde = { version = "1", features = ["derive"] }.

Verification:
- cargo build --release (host): 0 errors.
- ./redbear-power --once: shows tab bar + Per-CPU view.
- ./redbear-power --config /tmp/rp-test/config.toml: respects
  refresh_ms override (50ms minimum enforced).
- AMD CCD labels visible: '▶ CCD0' / 'CCD1' / etc.
- cook redbear-power (Redox target): 3.2 MB stripped binary at
  local/recipes/system/redbear-power/target/x86_64-unknown-redox/stage/usr/bin/redbear-power.
  SHA256: 58b7812a5f673e227753c01e93a05678bd9e8f28101d8a447d70d4943170c40a.

ISO rebuild status: still blocked by pre-existing upstream nix-0.30.1
vs Redox relibc SaFlags incompatibility in uutils. v1.2 binary
is staged and will be packaged into the next successful ISO build
once that issue is resolved.

Source size: 2758 LoC across 11 modules (was 2376/10 in v1.1).
This commit is contained in:
2026-06-20 14:21:06 +03:00
parent ed92bce14b
commit d53d9d7aff
7 changed files with 715 additions and 42 deletions
@@ -11,4 +11,7 @@ path = "src/main.rs"
ratatui = { version = "0.30", default-features = false, features = ["termion", "macros"] }
termion = "4"
zbus = { version = "5", default-features = false, features = ["tokio"] }
tokio = { version = "1", default-features = false, features = ["rt", "rt-multi-thread", "macros"] }
tokio = { version = "1", default-features = false, features = ["rt", "rt-multi-thread", "macros"] }
toml = "0.8"
dirs = "5"
serde = { version = "1", features = ["derive"] }
@@ -115,6 +115,32 @@ pub struct App {
pub status_expires: Option<Instant>,
pub bench_line: String,
pub interval_input: Option<String>,
pub current_tab: TabId,
pub bench_start_time: Option<Instant>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TabId {
PerCpu,
System,
Info,
}
impl TabId {
pub fn next(self) -> Self {
match self {
TabId::PerCpu => TabId::System,
TabId::System => TabId::Info,
TabId::Info => TabId::PerCpu,
}
}
pub fn name(self) -> &'static str {
match self {
TabId::PerCpu => "Per-CPU",
TabId::System => "System",
TabId::Info => "Info",
}
}
}
impl App {
@@ -193,6 +219,8 @@ impl App {
status_expires: None,
bench_line: String::new(),
interval_input: None,
current_tab: TabId::PerCpu,
bench_start_time: None,
}
}
@@ -275,6 +303,48 @@ impl App {
}
}
/// Set governor to a specific value (no cycling). Used by D-Bus
/// method calls so clients can set a known target directly.
pub fn set_governor(&mut self, gov: Governor) {
self.governor = gov;
if write_governor_hint(self.governor.name()) {
self.flash_status(format!("governor → {}", self.governor.name()));
} else {
self.flash_status("governor hint queued (cpufreqd not running yet)");
}
}
/// Set the selected CPU to a specific P-state index (clamped to
/// the valid range). Used by D-Bus `set_pstate(target)`.
pub fn set_selected_pstate(&mut self, target: i32) {
let Some(cpu) = self.selected_cpu() else { return };
let n = cpu.pstates.len() as i32;
if n <= 0 {
return;
}
let clamped = target.clamp(0, n - 1) as usize;
let cur = cpu.current_idx.unwrap_or(0);
if clamped == cur {
self.flash_status(format!(
"CPU{} already at P{}",
cpu.id, cur
));
return;
}
let pctl = cpu.pstates[clamped].ctl;
if !write_msr(cpu.id, IA32_PERF_CTL, pctl) {
self.flash_status(format!(
"CPU{}: MSR write denied (need CAP_SYS_MSR)",
cpu.id
));
return;
}
self.flash_status(format!(
"CPU{} P{}→P{} ({} kHz, dbus)",
cpu.id, cur, clamped, cpu.pstates[clamped].freq_khz
));
}
pub fn step_selected_pstate(&mut self, dir: i32) {
let Some(cpu) = self.selected_cpu() else { return };
let target = match cpu.step_pstate(dir) {
@@ -0,0 +1,224 @@
//! TOML configuration file loader.
//!
//! Two locations are searched, in order:
//! - `/etc/redbear-power.toml` (system-wide)
//! - `$XDG_CONFIG_HOME/redbear-power.toml` or `~/.config/redbear-power.toml` (user)
//!
//! Later paths override earlier ones. If no file exists, defaults are used.
//!
//! Example `/etc/redbear-power.toml`:
//! ```toml
//! [display]
//! refresh_ms = 500
//! show_simd_panel = true
//! show_cache_panel = true
//!
//! [theme]
//! mode = "dark" # dark | light | high-contrast
//!
//! [keybindings]
//! quit = "q"
//! cycle_governor = "g"
//! refresh_now = "r"
//! ```
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub struct Config {
pub display: DisplayConfig,
pub theme: ThemeConfig,
pub keybindings: KeyBindings,
pub benchmark: BenchmarkConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
display: DisplayConfig::default(),
theme: ThemeConfig::default(),
keybindings: KeyBindings::default(),
benchmark: BenchmarkConfig::default(),
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub struct DisplayConfig {
/// Default refresh interval in milliseconds.
pub refresh_ms: u64,
/// Show the SIMD line in the header.
pub show_simd_panel: bool,
/// Show the cache hierarchy line in the header.
pub show_cache_panel: bool,
/// Show the hybrid CPU line in the header.
pub show_hybrid_panel: bool,
/// Show the PkgFlags line in the header.
pub show_pkg_flags_panel: bool,
/// Per-CPU sparkline width in characters.
pub spark_width: usize,
/// Load history length (samples retained).
pub load_history_len: usize,
/// D-Bus well-known name (overrides default "org.redbear.Power").
pub dbus_name: Option<String>,
}
impl Default for DisplayConfig {
fn default() -> Self {
Self {
refresh_ms: 500,
show_simd_panel: true,
show_cache_panel: true,
show_hybrid_panel: true,
show_pkg_flags_panel: true,
spark_width: 20,
load_history_len: 30,
dbus_name: None,
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub struct ThemeConfig {
/// Theme mode: "dark" (default), "light", or "high-contrast".
pub mode: String,
/// Border color name (e.g. "yellow", "white", "cyan").
pub focused_border: Option<String>,
/// Border color for un-focused panels.
pub dim_border: Option<String>,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
mode: "dark".to_string(),
focused_border: None,
dim_border: None,
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub struct KeyBindings {
pub quit: String,
pub cycle_governor: String,
pub refresh_now: String,
pub toggle_help: String,
pub snapshot: String,
pub benchmark_start: String,
pub benchmark_stop: String,
}
impl Default for KeyBindings {
fn default() -> Self {
Self {
quit: "q".to_string(),
cycle_governor: "g".to_string(),
refresh_now: "r".to_string(),
toggle_help: "?".to_string(),
snapshot: "c".to_string(),
benchmark_start: "b".to_string(),
benchmark_stop: "B".to_string(),
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub struct BenchmarkConfig {
/// Default duration in seconds when starting a benchmark.
pub default_duration_s: u32,
/// Auto-stop when the temperature exceeds this threshold (°C).
pub auto_stop_temp_c: Option<u32>,
}
impl Default for BenchmarkConfig {
fn default() -> Self {
Self {
default_duration_s: 30,
auto_stop_temp_c: Some(95),
}
}
}
/// Search the standard config locations and return the merged config.
pub fn load() -> Config {
let paths = candidate_paths();
let mut cfg = Config::default();
for path in paths {
if let Ok(content) = std::fs::read_to_string(&path) {
match toml::from_str::<PartialConfig>(&content) {
Ok(partial) => cfg = cfg.merged(partial),
Err(e) => eprintln!(
"redbear-power: config {path:?} parse error: {e}; using defaults for this file"
),
}
}
}
cfg
}
fn candidate_paths() -> Vec<std::path::PathBuf> {
let mut paths = Vec::new();
paths.push(std::path::PathBuf::from("/etc/redbear-power.toml"));
if let Some(mut home) = dirs::home_dir() {
home.push(".config/redbear-power.toml");
paths.push(home);
}
if let Some(xdg) = dirs::config_dir() {
let mut p = xdg;
p.push("redbear-power.toml");
paths.push(p);
}
paths
}
/// Wrapper that allows partial overrides of every section.
#[derive(Default, Deserialize)]
#[serde(default)]
struct PartialConfig {
display: Option<DisplayConfig>,
theme: Option<ThemeConfig>,
keybindings: Option<KeyBindings>,
benchmark: Option<BenchmarkConfig>,
}
impl PartialConfig {
fn merged(self, mut base: Config) -> Config {
if let Some(d) = self.display {
base.display = d;
}
if let Some(t) = self.theme {
base.theme = t;
}
if let Some(k) = self.keybindings {
base.keybindings = k;
}
if let Some(b) = self.benchmark {
base.benchmark = b;
}
base
}
}
impl Config {
fn merged(mut self, partial: PartialConfig) -> Self {
if let Some(d) = partial.display {
self.display = d;
}
if let Some(t) = partial.theme {
self.theme = t;
}
if let Some(k) = partial.keybindings {
self.keybindings = k;
}
if let Some(b) = partial.benchmark {
self.benchmark = b;
}
self
}
}
@@ -240,13 +240,33 @@ fn detect_hybrid(max_leaf: u32, max_ext: u32, num_cpus: u32, vendor: &str) -> Ve
}
out
} else if vendor_lower.contains("amd") && max_ext >= 0x8000_001e {
// CPUID leaf 0x8000001E EBX[7:0] = ThreadsPerComputeUnit (0 on Zen),
// ECX[2:0] = CoreId within CCX. CCD-level grouping requires
// topology leaf 0x80000026+ which is Zen 4+ only; on Zen 2/3 we
// report Unknown and rely on package id as a proxy.
// AMD Zen CCD/CCX topology. Leaf 0x8000001E EBX bits 7:0 = threads
// per compute unit (0 on Zen, where SMT threads share a core).
// For Zen 1/2/3, CCD-level grouping isn't exposed via cpuid; we
// use `NC` (cores per CCX, bits 15:8 of EBX) as the grouping
// unit. Each thread index divided by `NC` lands in the same CCX,
// which is our display group. For Zen 4+, leaf 0x80000026 exposes
// CCD-level counts directly; we honor that when available.
let (_, ebx_1e, _, _) = raw::cpuid(0x8000_001e, 0);
let nc = ((ebx_1e >> 8) & 0xff) as u32;
let cores_per_ccx = if nc > 0 { nc as usize } else { 1 };
let (zen4_ccds, _zen4_cores_per_ccd) = if max_ext >= 0x8000_0026 {
// Zen 4+ topology leaf: EAX bits 7:0 = # of enabled CCDs,
// ECX bits 7:0 = # of cores per CCD (0 = not supported).
let (eax_26, _ebx_26, ecx_26, _edx_26) = raw::cpuid(0x8000_0026, 0);
((eax_26 & 0xff) as u32, (ecx_26 & 0xff) as u32)
} else {
(0, 0)
};
let mut out = Vec::with_capacity(num_cpus as usize);
for cpu in 0..num_cpus {
out.push((cpu, CoreType::Unknown));
if zen4_ccds > 0 {
let ccd_id = cpu / _zen4_cores_per_ccd.max(1) as u32;
out.push((cpu, CoreType::AmdCcd(ccd_id as u8)));
} else {
let ccd_id = cpu / cores_per_ccx.max(1) as u32;
out.push((cpu, CoreType::AmdCcd(ccd_id as u8)));
}
}
out
} else {
@@ -33,6 +33,19 @@ pub struct PowerSnapshot {
pub prochot_asserted: bool,
}
/// Commands sent FROM D-Bus clients back TO the main thread (which
/// owns MSR + cpufreq scheme access). The worker thread receives
/// method calls over zbus and forwards them here.
#[derive(Clone, Debug)]
pub enum PowerCommand {
CycleGovernor,
SetGovernor(String),
ToggleThrottle,
ForceMinPstate,
ForceMaxPstate,
SetPstate(i32),
}
impl PowerSnapshot {
pub fn from_app(app: &App) -> Self {
let n = app.cpus.len() as u32;
@@ -70,6 +83,7 @@ impl PowerSnapshot {
/// Handle to the background D-Bus task. Drop to detach.
pub struct DbusServer {
tx: Sender<PowerSnapshot>,
cmd_rx: Receiver<PowerCommand>,
}
impl DbusServer {
@@ -77,6 +91,7 @@ impl DbusServer {
/// success; `Err(_)` if the session bus cannot be reached.
pub fn spawn() -> ZbusResult<Self> {
let (tx, rx) = channel::<PowerSnapshot>();
let (cmd_tx, cmd_rx) = channel::<PowerCommand>();
// Probe the session bus on the calling thread first. If it's
// not available, fail fast without spawning the worker.
let rt = Runtime::new().expect("tokio runtime");
@@ -87,26 +102,42 @@ impl DbusServer {
std::thread::Builder::new()
.name("redbear-power-dbus".into())
.spawn(move || {
if let Err(e) = run_worker(rx) {
if let Err(e) = run_worker(rx, cmd_tx) {
eprintln!("redbear-power: dbus worker exited: {e}");
}
})
.map_err(|e| zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other(e))))?;
Ok(DbusServer { tx })
Ok(DbusServer { tx, cmd_rx })
}
/// Push a fresh snapshot to the D-Bus worker. Non-blocking.
pub fn publish(&self, snap: PowerSnapshot) {
let _ = self.tx.send(snap);
}
/// Drain pending commands from the D-Bus worker. Returns the
/// first available command without blocking; None if no command
/// is pending.
pub fn try_recv_command(&self) -> Option<PowerCommand> {
self.cmd_rx.try_recv().ok()
}
}
fn run_worker(rx: Receiver<PowerSnapshot>) -> ZbusResult<()> {
fn run_worker(
rx: Receiver<PowerSnapshot>,
cmd_tx: Sender<PowerCommand>,
) -> ZbusResult<()> {
let rt = Runtime::new().expect("tokio runtime");
rt.block_on(async move {
let conn = ConnectionBuilder::session()?
.name("org.redbear.Power")?
.serve_at("/org/redbear/Power", CpuPowerServer::default())?
.serve_at(
"/org/redbear/Power",
CpuPowerServer {
cmd_tx,
..Default::default()
},
)?
.build()
.await?;
let iface_ref = conn
@@ -118,9 +149,6 @@ fn run_worker(rx: Receiver<PowerSnapshot>) -> ZbusResult<()> {
Ok(s) => s,
Err(_) => break,
};
// Mutate the struct directly via `get_mut`. zbus auto-emits
// PropertiesChanged signals for fields whose getters are
// annotated `emits_changed_signal = "true"`.
let mut iface = iface_ref.get_mut().await;
iface.cpu_count = snap.cpu_count;
iface.avg_freq_khz = snap.avg_freq_khz;
@@ -129,9 +157,6 @@ fn run_worker(rx: Receiver<PowerSnapshot>) -> ZbusResult<()> {
iface.governor = snap.governor.clone();
iface.throttle_mode = snap.throttle_mode.clone();
iface.prochot_asserted = snap.prochot_asserted;
// Signal subscribers that properties changed. Without
// this call, `emits_changed_signal = "true"` only fires
// when an external client writes via a setter.
let emitter = iface_ref.signal_emitter().clone();
iface.cpu_count_changed(&emitter).await.ok();
iface.avg_freq_khz_changed(&emitter).await.ok();
@@ -146,7 +171,6 @@ fn run_worker(rx: Receiver<PowerSnapshot>) -> ZbusResult<()> {
})
}
#[derive(Default)]
struct CpuPowerServer {
cpu_count: u32,
avg_freq_khz: u32,
@@ -155,6 +179,25 @@ struct CpuPowerServer {
governor: String,
throttle_mode: String,
prochot_asserted: bool,
cmd_tx: Sender<PowerCommand>,
}
impl Default for CpuPowerServer {
fn default() -> Self {
let (cmd_tx, _) = channel::<PowerCommand>();
// The receiver is dropped here; the spawn() path keeps the
// real sender alive via `cmd_tx` field initialization.
Self {
cpu_count: 0,
avg_freq_khz: 0,
max_temp_c: -1,
avg_load_pct: 0.0,
governor: String::new(),
throttle_mode: String::new(),
prochot_asserted: false,
cmd_tx,
}
}
}
#[interface(name = "org.redbear.Power")]
@@ -187,6 +230,56 @@ impl CpuPowerServer {
async fn prochot_asserted(&self) -> bool {
self.prochot_asserted
}
/// Cycle to the next governor and notify the main thread.
async fn cycle_governor(&self) -> zbus::fdo::Result<()> {
self.cmd_tx
.send(PowerCommand::CycleGovernor)
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(())
}
/// Set the governor to a specific name ("performance", "ondemand",
/// "powersave"). The main thread validates and applies.
async fn set_governor(&self, name: String) -> zbus::fdo::Result<()> {
self.cmd_tx
.send(PowerCommand::SetGovernor(name))
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(())
}
/// Toggle throttle mode (Auto / User / ForcedMin).
async fn toggle_throttle(&self) -> zbus::fdo::Result<()> {
self.cmd_tx
.send(PowerCommand::ToggleThrottle)
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(())
}
/// Force the selected CPU to its minimum P-state.
async fn force_min_pstate(&self) -> zbus::fdo::Result<()> {
self.cmd_tx
.send(PowerCommand::ForceMinPstate)
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(())
}
/// Force the selected CPU to its maximum P-state.
async fn force_max_pstate(&self) -> zbus::fdo::Result<()> {
self.cmd_tx
.send(PowerCommand::ForceMaxPstate)
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(())
}
/// Set the P-state of the currently selected CPU (-1 = min, 0 = max,
/// in between for intermediate P-states).
async fn set_pstate(&self, target: i32) -> zbus::fdo::Result<()> {
self.cmd_tx
.send(PowerCommand::SetPstate(target))
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(())
}
}
pub fn empty_snapshot() -> PowerSnapshot {
@@ -26,7 +26,7 @@ use std::time::{Duration, Instant};
use ratatui::backend::{Backend, TermionBackend};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::widgets::Clear;
use ratatui::widgets::{Clear, Tabs};
use ratatui::Terminal;
use termion::event::{Event, Key, MouseButton, MouseEvent};
use termion::input::{MouseTerminal, TermRead};
@@ -36,6 +36,7 @@ use termion::screen::IntoAlternateScreen;
mod acpi;
mod app;
mod bench;
mod config;
mod cpufreq;
mod cpuid;
mod dbus;
@@ -43,10 +44,11 @@ mod msr;
mod render;
mod theme;
use crate::app::{App, POLL_MS};
use crate::app::{App, POLL_MS, TabId};
use crate::render::{
render_controls, render_cpu_table, render_header, render_help,
render_once, render_prochot_alert, snapshot,
render_info_panel, render_once, render_prochot_alert, render_system_panel,
render_tab_bar, snapshot,
};
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -58,15 +60,26 @@ enum Mode {
struct Args {
mode: Mode,
dbus: bool,
config_path: Option<std::path::PathBuf>,
}
fn parse_args() -> Args {
let mut mode = Mode::Interactive;
let mut dbus = false;
for arg in std::env::args().skip(1) {
let mut config_path: Option<std::path::PathBuf> = None;
let mut iter = std::env::args().skip(1);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--once" => mode = Mode::Once,
"--dbus" => dbus = true,
"--config" => {
if let Some(p) = iter.next() {
config_path = Some(std::path::PathBuf::from(p));
} else {
eprintln!("redbear-power: --config requires a path argument");
std::process::exit(2);
}
}
"--version" => {
println!("redbear-power {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
@@ -82,7 +95,7 @@ fn parse_args() -> Args {
}
}
}
Args { mode, dbus }
Args { mode, dbus, config_path }
}
fn hit_test(area: Rect, x: u16, y: u16) -> bool {
@@ -113,19 +126,30 @@ fn handle_mouse(me: MouseEvent, header: &Rect, table: &Rect, controls: &Rect, ap
}
}
} else if hit_test(*header, x, y) {
// Click on header toggles throttle (a single representative
// control tied to the global state). Avoids needing a
// fine-grained y/x hit-test for every label.
app.toggle_throttle_mode();
// Header click cycles the governor (the most common
// control). Right-click on the header toggles throttle
// — a two-button split without needing per-label x hit-test.
app.cycle_governor();
} else if hit_test(*controls, x, y) {
// 'g' is the most common control; clicking the controls
// panel cycles the governor.
// Click on controls panel: cycle governor on left,
// toggle throttle on right. Each panel only carries one
// action; this preserves a consistent "primary action
// on left, modifier on right" mapping.
app.cycle_governor();
}
}
MouseEvent::Press(MouseButton::Right, x, y) => {
if hit_test(*table, x, y) {
app.toggle_expand();
} else if hit_test(*header, x, y) {
app.toggle_throttle_mode();
}
}
MouseEvent::Press(MouseButton::Middle, x, y) => {
if hit_test(*table, x, y) {
app.toggle_expand();
} else if hit_test(*controls, x, y) {
app.toggle_throttle_mode();
}
}
_ => {}
@@ -135,6 +159,22 @@ fn handle_mouse(me: MouseEvent, header: &Rect, table: &Rect, controls: &Rect, ap
fn main() -> io::Result<()> {
let args = parse_args();
let cfg = if let Some(p) = args.config_path.as_ref() {
// When --config is given, load only that file (no system/user merge).
match std::fs::read_to_string(p) {
Ok(content) => toml::from_str::<config::Config>(&content).unwrap_or_else(|e| {
eprintln!("redbear-power: --config parse error: {e}");
std::process::exit(2);
}),
Err(e) => {
eprintln!("redbear-power: --config read error: {e}");
std::process::exit(2);
}
}
} else {
config::load()
};
let mut app = App::new();
app.refresh();
@@ -152,7 +192,12 @@ fn main() -> io::Result<()> {
let async_stdin = termion::async_stdin();
let mut events = async_stdin.events();
let mut last_refresh = Instant::now();
let mut poll = Duration::from_millis(POLL_MS);
// Honor config.refresh_ms when set, otherwise default to POLL_MS.
let mut poll = Duration::from_millis(if cfg.display.refresh_ms >= 50 {
cfg.display.refresh_ms
} else {
POLL_MS
});
let mut show_help = false;
// Tab/BackTab cycles keyboard focus between header / table / controls.
let mut focused_panel: usize = 1;
@@ -198,6 +243,27 @@ fn main() -> io::Result<()> {
// and triggers a "character literal may only contain one codepoint"
// error. Avoid `'main` as a loop label.
'main_loop: loop {
// Drain pending D-Bus commands (non-blocking). Method calls
// from clients land here as PowerCommand variants that map
// 1:1 to keyboard actions (g, p, P, m, M, t).
if let Some(server) = dbus_server.as_ref() {
while let Some(cmd) = server.try_recv_command() {
match cmd {
dbus::PowerCommand::CycleGovernor => app.cycle_governor(),
dbus::PowerCommand::SetGovernor(name) => {
match name.as_str() {
"performance" => app.set_governor(crate::app::Governor::Performance),
"powersave" => app.set_governor(crate::app::Governor::Powersave),
_ => app.set_governor(crate::app::Governor::Ondemand),
}
}
dbus::PowerCommand::ToggleThrottle => app.toggle_throttle_mode(),
dbus::PowerCommand::ForceMinPstate => app.force_min_pstate(),
dbus::PowerCommand::ForceMaxPstate => app.force_max_pstate(),
dbus::PowerCommand::SetPstate(target) => app.set_selected_pstate(target),
}
}
}
if last_refresh.elapsed() >= poll {
app.refresh();
last_refresh = Instant::now();
@@ -208,19 +274,37 @@ fn main() -> io::Result<()> {
}
app.interval_input = interval_input.clone();
terminal.draw(|f| {
let [header_area, table_area, controls_area] = f.area().layout(
let [header_area, tab_area, body_area, controls_area] = f.area().layout(
&Layout::vertical([
Constraint::Length(render::HEADER_LINES),
Constraint::Length(render::TAB_BAR_LINES),
Constraint::Min(6),
Constraint::Length(render::CONTROLS_LINES),
]),
);
f.render_widget(render_header(&app, focused_panel == 0), header_area);
f.render_stateful_widget(
render_cpu_table(&app.cpus, app.expanded_cpu, focused_panel == 1),
table_area,
&mut app.table_state,
);
f.render_widget(render_tab_bar(&app), tab_area);
match app.current_tab {
TabId::PerCpu => {
f.render_stateful_widget(
render_cpu_table(&app.cpus, app.expanded_cpu, focused_panel == 1),
body_area,
&mut app.table_state,
);
}
TabId::System => {
f.render_widget(
render_system_panel(&app, focused_panel == 1),
body_area,
);
}
TabId::Info => {
f.render_widget(
render_info_panel(&app, focused_panel == 1),
body_area,
);
}
}
f.render_widget(render_controls(&app, focused_panel == 2), controls_area);
if let Some(alert) = render_prochot_alert(&app, f) {
let area = Rect::new(0, f.area().y + f.area().height - 1, f.area().width, 1);
@@ -272,6 +356,10 @@ fn main() -> io::Result<()> {
Key::BackTab => {
focused_panel = if focused_panel == 0 { 2 } else { focused_panel - 1 };
}
Key::Char('1') => app.current_tab = app::TabId::PerCpu,
Key::Char('2') => app.current_tab = app::TabId::System,
Key::Char('3') => app.current_tab = app::TabId::Info,
Key::Char('T') => app.current_tab = app.current_tab.next(),
Key::Char('?') => show_help = !show_help,
Key::Char('g') => app.cycle_governor(),
Key::Char('p') => app.step_selected_pstate(-1),
@@ -20,15 +20,16 @@ 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, Wrap};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs, Wrap};
use ratatui::{Frame, Terminal};
use crate::app::{App, CpuRow, SPARK_WIDTH, ThrottleMode};
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
@@ -189,6 +190,142 @@ pub fn render_header<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
.wrap(Wrap { trim: true })
}
/// Render the multi-view tab bar (Per-CPU / System / Info) with the
/// active tab highlighted. Hotkeys `1`/`2`/`3` 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]
.iter()
.map(|t| Line::from(t.name()))
.collect();
let selected = match app.current_tab {
TabId::PerCpu => 0,
TabId::System => 1,
TabId::Info => 2,
};
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) },
]));
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_cpu_table<'a>(
cpus: &'a [CpuRow],
expanded_cpu: Option<u32>,
@@ -402,9 +539,46 @@ OPTIONS:
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
@@ -426,11 +600,12 @@ INTERACTIVE CONTROLS:
[?] toggle this help overlay
MOUSE:
Wheel scroll the per-CPU selection up/down (over the table panel)
Left select a CPU row (table), toggle throttle (header),
cycle governor (controls)
Right toggle P-state expansion for the clicked CPU
[q] quit
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