From d53d9d7aff1803df02f7905e30111bd321ba81f5 Mon Sep 17 00:00:00 2001 From: vasilito Date: Sat, 20 Jun 2026 14:21:06 +0300 Subject: [PATCH] =?UTF-8?q?redbear-power:=20v1.2=20=E2=80=94=20config=20fi?= =?UTF-8?q?le,=20AMD=20CCD,=20tab=20system,=20D-Bus=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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). --- .../system/redbear-power/source/Cargo.toml | 5 +- .../system/redbear-power/source/src/app.rs | 70 ++++++ .../system/redbear-power/source/src/config.rs | 224 ++++++++++++++++++ .../system/redbear-power/source/src/cpuid.rs | 30 ++- .../system/redbear-power/source/src/dbus.rs | 115 ++++++++- .../system/redbear-power/source/src/main.rs | 124 ++++++++-- .../system/redbear-power/source/src/render.rs | 189 ++++++++++++++- 7 files changed, 715 insertions(+), 42 deletions(-) create mode 100644 local/recipes/system/redbear-power/source/src/config.rs diff --git a/local/recipes/system/redbear-power/source/Cargo.toml b/local/recipes/system/redbear-power/source/Cargo.toml index b39bd96fcb..1a6114fe39 100644 --- a/local/recipes/system/redbear-power/source/Cargo.toml +++ b/local/recipes/system/redbear-power/source/Cargo.toml @@ -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"] } \ No newline at end of file +tokio = { version = "1", default-features = false, features = ["rt", "rt-multi-thread", "macros"] } +toml = "0.8" +dirs = "5" +serde = { version = "1", features = ["derive"] } \ No newline at end of file diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index cb0f246767..5eae226779 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -115,6 +115,32 @@ pub struct App { pub status_expires: Option, pub bench_line: String, pub interval_input: Option, + pub current_tab: TabId, + pub bench_start_time: Option, +} + +#[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) { diff --git a/local/recipes/system/redbear-power/source/src/config.rs b/local/recipes/system/redbear-power/source/src/config.rs new file mode 100644 index 0000000000..4d2af6202d --- /dev/null +++ b/local/recipes/system/redbear-power/source/src/config.rs @@ -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, +} + +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, + /// Border color for un-focused panels. + pub dim_border: Option, +} + +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, +} + +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::(&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 { + 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, + theme: Option, + keybindings: Option, + benchmark: Option, +} + +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 + } +} \ No newline at end of file diff --git a/local/recipes/system/redbear-power/source/src/cpuid.rs b/local/recipes/system/redbear-power/source/src/cpuid.rs index 0e9b086329..bae60e5d5e 100644 --- a/local/recipes/system/redbear-power/source/src/cpuid.rs +++ b/local/recipes/system/redbear-power/source/src/cpuid.rs @@ -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 { diff --git a/local/recipes/system/redbear-power/source/src/dbus.rs b/local/recipes/system/redbear-power/source/src/dbus.rs index 8a1cda0a0f..dcbba1031a 100644 --- a/local/recipes/system/redbear-power/source/src/dbus.rs +++ b/local/recipes/system/redbear-power/source/src/dbus.rs @@ -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, + cmd_rx: Receiver, } impl DbusServer { @@ -77,6 +91,7 @@ impl DbusServer { /// success; `Err(_)` if the session bus cannot be reached. pub fn spawn() -> ZbusResult { let (tx, rx) = channel::(); + let (cmd_tx, cmd_rx) = channel::(); // 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 { + self.cmd_rx.try_recv().ok() + } } -fn run_worker(rx: Receiver) -> ZbusResult<()> { +fn run_worker( + rx: Receiver, + cmd_tx: Sender, +) -> 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) -> 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) -> 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) -> 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, +} + +impl Default for CpuPowerServer { + fn default() -> Self { + let (cmd_tx, _) = channel::(); + // 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 { diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index 81f6b9c5d9..438f098dc0 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -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, } 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 = 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::(&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), diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index a5d3c2cfca..75524c87e4 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -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> = [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> = 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::() / 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> = 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, @@ -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