redbear-power: v1.6 — Battery tab (power_supply)

Adds the 5th tab in the multi-view system: Battery, reading
power_supply data from /sys/class/power_supply/BAT*/ on Linux hosts.

New module battery.rs (128 lines):
- BatteryInfo struct with 15 fields (available, name, status,
  capacity_percent, energy_now_wh, energy_full_wh, power_now_w,
  voltage_now_v, time_to_empty_s, time_to_full_s, cycle_count,
  technology, model_name, manufacturer, serial_number)
- find_battery_dir() scans /sys/class/power_supply/ for type==Battery
- read() populates all fields with inline µWh→Wh, µV→V conversion
- health_percent() computes current_charge / full_charge ratio
- display/display_u32/display_u64/display_f64 helpers
- format_duration(secs) — Xh Ym / Ym Zs / Zs
- RBP_BATTERY_PATH env override (testing + dev workflow)

Updated app.rs:
- New field battery: BatteryInfo, initialized once in App::new()
- TabId::Battery variant (5th tab)
- TabId::next() cycles PerCpu → System → Info → Motherboard → Battery

Updated render.rs:
- New render_battery_panel(app, focused) with 3 section blocks
  (Identity / State / Power). If !available, shows
  '(no battery detected — /sys/class/power_supply/BAT* not present)'.
- render_tab_bar() updated for 5 tabs with hotkey 1/2/3/4/5 mapping
- render_once now dumps Battery panel for headless verification

Updated main.rs:
- mod battery; declaration
- New dispatch arm TabId::Battery => render_battery_panel
- Hotkey 5 jumps to Battery tab directly
- render_battery_panel added to imports

Mock battery smoke test (RBP_BATTERY_PATH=/tmp/fake-battery):
- Manufacturer: MSI, Model: MPG X670E, Technology: Li-ion, Cycles: 127
- Status: Discharging, Capacity: 67%, Health: 67%
- Energy: 33.50 Wh / 50.00 Wh (µWh→Wh conversion verified)
- Power: 8.50 W, Voltage: 12.50 V (µV→V conversion verified)
- Time to empty: 3h 0m, Time to full: ? (correctly hidden when 0)

On desktop (no battery):
(no battery detected — /sys/class/power_supply/BAT* not present)

Source state: 4359 LoC across 15 modules (v1.5: 4117/14).
Redox stripped: 3.8 MB (SHA256 c6fca172...).

Docs: improvement plan §30, CONSOLE-TO-KDE §3.3.2 v1.6,
RATATUI-APP-PATTERNS §13.18 + §14.
This commit is contained in:
2026-06-20 17:44:11 +03:00
parent e4987256f7
commit a4dc44a8e0
7 changed files with 545 additions and 4 deletions
+59
View File
@@ -1034,6 +1034,65 @@ Cross-compiled binary: 3.7 MB stripped Redox ELF
Until then, the Motherboard panel on Redox honestly reports empty data
(rather than fake values) — per the zero-stub policy.
#### v1.6 Battery Tab (2026-06-20)
Per the user's "v1.6 = Battery tab (Recommended)" directive, v1.6
ships the **Battery tab** as the 5th tab in the multi-view system.
| Item | Status |
|------|--------|
| `battery.rs` (NEW, 128 LoC) — `BatteryInfo` with 15 fields + per-field sysfs read | ✅ |
| `TabId::Battery` variant + cycle order (`PerCpu → System → Info → Motherboard → Battery`) | ✅ |
| Hotkey `5` jumps directly to Battery tab | ✅ |
| `render_battery_panel()` with 3 sections: Identity / State / Power | ✅ |
| `RBP_BATTERY_PATH` env override (useful for testing + dev workflow) | ✅ |
| `render_once` now dumps Battery panel for headless verification | ✅ |
**Data sources opened at runtime** (when battery present):
- `/sys/class/power_supply/BAT0/type` — device type filter
- `status`, `capacity`, `energy_now`, `energy_full`, `power_now`,
`voltage_now`, `time_to_empty`, `time_to_full`, `cycle_count`,
`technology`, `model_name`, `manufacturer`, `serial_number` (13 fields)
**Mock battery smoke test** (`RBP_BATTERY_PATH=/tmp/fake-battery`):
```
Manufacturer: MSI Technology: Li-ion Cycles: 127
Status: Discharging Capacity: 67% Health: 67%
Energy: 33.50 Wh / 50.00 Wh
Power: 8.50 W Voltage: 12.50 V
Time to empty: 3h 0m Time to full: ?
```
µWh → Wh conversion verified (33,500,000 µWh → 33.50 Wh); µV → V
conversion verified (12,500,000 µV → 12.50 V); `time_to_full=0`
correctly hidden.
**On actual desktop host** (no battery):
```
(no battery detected — /sys/class/power_supply/BAT* not present)
```
**v1.6 source state**: 4359 LoC across **15 modules** (was 4117/14 in
v1.5). New module: `battery.rs` (128 lines).
Cross-compiled binary: 3.8 MB stripped Redox ELF
(SHA256 `c6fca1728faff9edd053b933f0c57075e25dfe52450b7ab604d04d5024b1cc88`).
**Forward work on Redox target** (deferred to v1.7+):
1. **`power_supply` scheme daemon** — exposes battery state via
`/scheme/power_supply/BAT0/{status,capacity,...}` (mirrors sysfs).
2. **ACPI battery object parser** — read `_BST` (battery status) and
`_BIF` (battery information) AML methods; convert to `BatteryInfo`
fields.
3. **Per-tick refresh** — battery state should be polled at 2-5 Hz,
not read once at startup. Move `BatteryInfo::read()` into
`App::refresh()` with 5-tick throttling.
4. **redbear-power fallback** — `find_battery_dir()` tries Redox
scheme first, then `/sys/class/power_supply/` (Linux host).
Until then, the Battery panel on Redox honestly reports empty data
(rather than fake values) — per the zero-stub policy.
### 3.4 D-Bus
| Component | Status | Detail |
+70 -2
View File
@@ -1092,8 +1092,8 @@ Use the canonical pattern from §1 (poll + sleep).
| Modular crates | Single crate | Split (3-4 crates) | More granular split |
### 13.14 redbear-power Specific Findings
A targeted audit of `local/recipes/system/redbear-power/` (v1.5, 4117 LoC
across 14 modules) produced these actionable findings:
A targeted audit of `local/recipes/system/redbear-power/` (v1.6, 4359 LoC
across 15 modules) produced these actionable findings:
| Severity | Finding | Fix |
|----------|---------|-----|
@@ -1111,6 +1111,7 @@ across 14 modules) produced these actionable findings:
| feature | No Linux-host fallbacks (hardcoded `/scheme/sys/...` paths) | Implemented in v1.3 (`platform.rs` runtime probe + per-module fallbacks) |
| feature | No memory or OS info display | Implemented in v1.4 (`meminfo.rs` module + `mem_bar_line` helper) |
| feature | No Motherboard / DMI tab | Implemented in v1.5 (`dmi.rs` module + `TabId::Motherboard`) |
| feature | No Battery tab | Implemented in v1.6 (`battery.rs` module + `TabId::Battery`) |
Full plan: see `local/docs/redbear-power-improvement-plan.md`.
@@ -1212,6 +1213,62 @@ change at runtime) and cheap (≤ 20 sysfs reads = < 1 ms). The
`Option<String>` per-field design means a permission error on
`product_serial` (root-only) doesn't disable the entire Motherboard tab.
### 13.18 v1.6 Module Pattern: `battery.rs` with Empty-State Short-Circuit
v1.6 added `battery.rs` (128 lines) as a self-contained power-source
data source module. The pattern differs from `dmi.rs` because the
battery is sometimes **entirely absent** (desktop without UPS, server,
container) — not just partially readable.
```rust
// battery.rs skeleton
#[derive(Default, Clone, Debug)]
pub struct BatteryInfo {
pub available: bool, // ← false on desktop, true on laptop
pub status: Option<String>,
pub capacity_percent: Option<u32>,
pub energy_now_wh: Option<f64>,
// ... 12 more fields
}
impl BatteryInfo {
pub fn read() -> Self {
let Some(base) = Self::find_battery_dir() else {
return Self::default(); // ← available=false
};
// ... populate all fields from sysfs
}
}
```
Key conventions:
- **`available: bool` is a top-level field**, not just `is_empty()`.
Distinguishes "battery doesn't exist" from "battery exists but I can't
read all its fields" — both render differently:
- `!available``(no battery detected — /sys/class/power_supply/BAT* not present)`
- `available && all fields None` → wall of `?` characters + empty sections
- **`RBP_BATTERY_PATH` env override** — redirects `find_battery_dir()` to
a fixture directory. Useful for:
- **Testing**: create a fake `/tmp/fake-battery/BAT0/` with realistic
values and verify the panel renders correctly without needing a
real laptop.
- **Dev workflow**: redirect to a non-standard sysfs mount.
- **Unit conversion inline** — `energy_now` is µWh, divide by 1_000_000
at read time. Don't store raw µWh and convert at render time.
- **`format_duration(secs)` helper** — converts seconds → "3h 0m" /
"0m 5s" / "0s". Hidden when `secs == 0` (matches `?` for "not
applicable" semantics, not "0 seconds remaining").
- **Per-tick refresh deferred** — battery state should ideally be
polled at 2-5 Hz, but `App::refresh()` doesn't call `read()` yet.
Reading once at startup is the safe default for the first iteration;
per-tick refresh is documented as v1.7 forward work.
Pattern rationale: many TUI monitoring tools (htop, btop, cpu-x) skip
the battery subsystem entirely on desktops. redbear-power follows the
**honest empty-state** pattern: if the source doesn't exist, say so
clearly in one line; don't render a wall of `?` characters that
confuses the user.
---
## 14. Cross-Reference: redbear-power as a Reference Implementation
@@ -1219,6 +1276,17 @@ change at runtime) and cheap (≤ 20 sysfs reads = < 1 ms). The
The `redbear-power` recipe (`local/recipes/system/redbear-power/`) is a useful
reference for new TUI apps because:
1. **Small enough to read in one sitting** (~4400 LoC across 15 modules)
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR + meminfo + DMI + battery
3. **Modern ratatui 0.30 patterns**`TableState`, modular layout, status bars, `Tabs` widget
4. **Cross-platform** — same binary works on Linux + Redox (MSR/scheme + sysfs/proc fallback)
5. **Well-documented** — extensive code comments + this doc + improvement plan
## 14. Cross-Reference: redbear-power as a Reference Implementation
The `redbear-power` recipe (`local/recipes/system/redbear-power/`) is a useful
reference for new TUI apps because:
1. **Small enough to read in one sitting** (~4100 LoC across 14 modules)
2. **Self-contained** — no D-Bus, no external state, just sysfs/MSR + meminfo + DMI
3. **Modern ratatui 0.30 patterns**`TableState`, modular layout, status bars, `Tabs` widget
@@ -1787,6 +1787,173 @@ Total: 4,117 LoC across 14 modules (v1.4: 3,864 LoC across 13 modules; +253 LoC,
---
## 30. v1.6 Battery Tab (2026-06-20)
Per the user's "commit v1.5 done. v1.6 = Battery tab (Recommended)"
directive, v1.6 ships the **Battery tab** as the 5th tab in the
multi-view system.
### 30.1 What was implemented
**New module `battery.rs` (128 lines)**:
- `BatteryInfo` struct with 15 fields: `available`, `name`, `status`,
`capacity_percent`, `energy_now_wh`, `energy_full_wh`, `power_now_w`,
`voltage_now_v`, `time_to_empty_s`, `time_to_full_s`, `cycle_count`,
`technology`, `model_name`, `manufacturer`, `serial_number`.
- `find_battery_dir()` — scans `/sys/class/power_supply/` for the first
device with `type == "Battery"`. Returns `None` if absent (desktop
without UPS).
- `read()` — populates all fields by reading each sysfs file
independently. Unit conversion (µWh → Wh, µV → V) handled inline.
- `health_percent()` — computes charge / full charge ratio.
- `display`, `display_u32`, `display_u64`, `display_f64` — render
helpers (Some → value, None → "?").
- `format_duration(secs)` — formats seconds → "Xh Ym" / "Ym Zs" / "Zs".
- `RBP_BATTERY_PATH` env override — useful for testing and dev workflow;
redirects `find_battery_dir()` to a fixture directory.
**Updated `app.rs`**:
- New field `pub battery: crate::battery::BatteryInfo`, initialized once
in `App::new()` (battery state changes but for now match DMI cadence
— read once at startup is the safe default; per-tick refresh is
forward work for v1.7+).
- `TabId::Battery` variant (5th tab).
- `TabId::next()` cycle: `PerCpu → System → Info → Motherboard → Battery → PerCpu`.
- `TabId::name()` returns `"Battery"`.
**Updated `render.rs`**:
- New `render_battery_panel(app, focused)` — produces a `Paragraph`
with 3 section blocks (Identity, State, Power). If `!bat.available`,
shows `(no battery detected — /sys/class/power_supply/BAT* not present)`
rather than a wall of `?` characters (zero-stub policy).
- `render_tab_bar()` updated for 5 tabs with hotkey mapping 1/2/3/4/5.
- `render_once` now dumps the Battery panel as a fourth snapshot.
**Updated `main.rs`**:
- `mod battery;` declaration.
- New dispatch arm `TabId::Battery => render_battery_panel(...)`.
- Hotkey `5` jumps to Battery tab directly.
- `render_battery_panel` added to imports.
### 30.2 Mock battery smoke test
Created `/tmp/fake-battery/BAT0/` with:
- `type=Battery`, `name=BAT0`, `status=Discharging`
- `capacity=67`, `energy_now=33500000` (µWh), `energy_full=50000000`
- `power_now=8500000` (µW = 8.5 W), `voltage_now=12500000` (µV = 12.5 V)
- `time_to_empty=10800` (3h), `time_to_full=0` (not charging)
- `cycle_count=127`, `technology=Li-ion`
- `model_name=MPG X670E`, `manufacturer=MSI`, `serial_number=ABC123`
Ran with `RBP_BATTERY_PATH=/tmp/fake-battery`:
```
--- Battery panel (verifies v1.6 power_supply) ---
┌ Battery ─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│Identity │
│Manufacturer: MSI │
│Model: MPG X670E │
│Technology: Li-ion │
│Serial: ABC123 │
│Cycles: 127 │
│ │
│State │
│Status: Discharging │
│Capacity: 67% │
│Energy: 33.50 Wh / 50.00 Wh │
│Health: 67% (current charge / full charge) │
│ │
│Power │
│Power: 8.50 W │
│Voltage: 12.50 V │
│Time to empty: 3h 0m │
│Time to full: ? │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
Verified:
- µWh → Wh conversion: 33,500,000 µWh → 33.50 Wh ✓
- µV → V conversion: 12,500,000 µV → 12.50 V ✓
- `time_to_full=0` correctly shows `?` (zero duration hidden, matches
the SMBIOS pattern of hiding empty fields)
- Health% computation: 33.50 / 50.00 × 100 = 67% ✓
- All 15 fields read and rendered in their respective sections
On the **actual host** (no battery), the panel correctly shows
`(no battery detected — /sys/class/power_supply/BAT* not present)`.
### 30.3 Build verification
| Build | Result |
|-------|--------|
| Linux host (`cargo build --release`) | ✅ 0 errors, 29 warnings (all pre-existing dead-code) |
| Linux host smoke (`./target/release/redbear-power --once`) | ✅ No-battery panel renders correctly |
| Linux host smoke with mock (`RBP_BATTERY_PATH=/tmp/fake-battery --once`) | ✅ Full battery panel renders correctly |
| Redox cross-compile (`cargo build --release --target=x86_64-unknown-redox`) | ✅ clean |
| Redox binary (unstripped) | 5,310,464 bytes |
| Redox binary (stripped) | 3,935,080 bytes (vs v1.5's 3,918,696 — +16 KB) |
| Linux binary (unstripped) | 5,418,968 bytes |
Cross-compile SHA256: `c6fca1728faff9edd053b933f0c57075e25dfe52450b7ab604d04d5024b1cc88`.
### 30.4 Forward work on Redox target
The `power_supply` sysfs class doesn't yet exist on Redox. Required work
for a populated Battery tab on Redox:
1. **`power_supply` scheme daemon** — exposes battery state via
`/scheme/power_supply/BAT0/{status,capacity,energy_now,...}` (mirrors
the sysfs layout on Linux).
2. **ACPI battery object parser** — read the `_BST` (battery status) and
`_BIF` (battery information) AML methods; convert to `BatteryInfo`
fields with same unit conversions.
3. **redbear-power fallback** — `find_battery_dir()` tries Redox scheme
first, then `/sys/class/power_supply/` (Linux host).
Until then, the Battery panel on Redox honestly reports empty data
(rather than fake values) — per the zero-stub policy.
### 30.5 Per-tick battery refresh (forward work, v1.7+)
`BatteryInfo::read()` is currently called once at `App::new()` time.
On a real laptop, battery state changes continuously (capacity drops,
power_now varies, time_to_empty decreases). For a useful real-time
view, the battery module needs to be polled at the same cadence as
per-CPU stats (500 ms default).
Implementation plan:
1. Move `battery: BatteryInfo` read into `App::refresh()`.
2. Add a `bat_available: bool` derived field to drive the empty-state
path (no need to keep `available: bool` in `BatteryInfo` if App
drives the cadence).
3. Add a 5-tick throttling (every 5th refresh = 2.5 sec at 500 ms)
to avoid hammering `/sys/class/power_supply/` at 2 Hz.
### 30.6 Final module structure
```
local/recipes/system/redbear-power/source/src/
├── main.rs (~483 lines) — event loop, key + mouse + D-Bus command dispatch
├── app.rs (~540) — App + CpuRow + TabId + meminfo + os_info + dmi + battery fields
├── render.rs (~1026) — header with Sources line, tab bar, 5 panels, controls
├── meminfo.rs (241) — /proc/meminfo + /etc/os-release + /proc/uptime + /etc/hostname
├── dmi.rs (118) — /sys/class/dmi/id/{sys,product,board,bios,chassis}
├── battery.rs (128) — NEW: /sys/class/power_supply/BAT*/{status,capacity,energy,...}
├── platform.rs (291) — runtime probe of MSR/PSS/load/gov/hwmon paths
├── acpi.rs (~233) — CPU enum + /proc/stat fallback + sysfs PSS
├── cpuid.rs (~369) — CPUID leaf decoding incl. Zen CCD topology
├── dbus.rs (~294) — D-Bus export via zbus 5
├── config.rs (~223) — TOML config file loader
├── bench.rs (122) — prime-sieve stress benchmark
├── msr.rs (~158) — MSR constants + Linux /dev/cpu fallback
├── cpufreq.rs (~62) — governor hint read/write + sysfs fallback
└── theme.rs (71) — central color palette
```
Total: 4,359 LoC across 15 modules (v1.5: 4,117 LoC across 14 modules; +242 LoC, +1 module).
---
## See Also
- **`local/docs/RATATUI-APP-PATTERNS.md`** §13 — the canonical ratatui 0.30 best-practices update that this plan is derived from. Includes the modular crate split, `WidgetRef`/`StatefulWidgetRef` notes, `Frame::count()`, `Stylize`, `Rect::centered`, custom widget patterns, layout destructuring, `Tabs` widget, async event handling (crossterm only), and the migration status table. Use this as the implementation guide while this doc is the roadmap.
@@ -118,6 +118,7 @@ pub struct App {
pub meminfo: crate::meminfo::MemInfo,
pub os_info: crate::meminfo::OsInfo,
pub dmi: crate::dmi::DmiInfo,
pub battery: crate::battery::BatteryInfo,
pub refresh_counter: u32,
pub status_msg: String,
pub status_expires: Option<Instant>,
@@ -133,6 +134,7 @@ pub enum TabId {
System,
Info,
Motherboard,
Battery,
}
impl TabId {
@@ -141,7 +143,8 @@ impl TabId {
TabId::PerCpu => TabId::System,
TabId::System => TabId::Info,
TabId::Info => TabId::Motherboard,
TabId::Motherboard => TabId::PerCpu,
TabId::Motherboard => TabId::Battery,
TabId::Battery => TabId::PerCpu,
}
}
pub fn name(self) -> &'static str {
@@ -150,6 +153,7 @@ impl TabId {
TabId::System => "System",
TabId::Info => "Info",
TabId::Motherboard => "Motherboard",
TabId::Battery => "Battery",
}
}
}
@@ -256,6 +260,7 @@ impl App {
meminfo: crate::meminfo::read_meminfo(),
os_info: crate::meminfo::read_os_info(),
dmi: crate::dmi::DmiInfo::read(),
battery: crate::battery::BatteryInfo::read(),
refresh_counter: 0,
}
}
@@ -0,0 +1,133 @@
//! Battery information via `sysfs` (`/sys/class/power_supply/BAT0/*`).
//!
//! Linux hosts expose laptop batteries through the `power_supply` class.
//! On Redox, no equivalent scheme exists yet, so `read_battery()` returns
//! an `available=false` `BatteryInfo` and the render layer skips the
//! panel entirely — per the zero-stub policy.
//!
//! The Redox target needs a `power_supply` scheme daemon (likely wired to
//! ACPI); this is forward work tracked in the v1.6 docs.
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const SYS_POWER_SUPPLY: &str = "/sys/class/power_supply";
#[derive(Default, Clone, Debug)]
pub struct BatteryInfo {
pub available: bool,
pub name: Option<String>,
pub status: Option<String>,
pub capacity_percent: Option<u32>,
pub energy_now_wh: Option<f64>,
pub energy_full_wh: Option<f64>,
pub power_now_w: Option<f64>,
pub voltage_now_v: Option<f64>,
pub time_to_empty_s: Option<u64>,
pub time_to_full_s: Option<u64>,
pub cycle_count: Option<u32>,
pub technology: Option<String>,
pub model_name: Option<String>,
pub manufacturer: Option<String>,
pub serial_number: Option<String>,
}
fn read_sysfs(path: &Path) -> Option<String> {
match fs::read_to_string(path) {
Ok(s) => {
let trimmed = s.trim().to_string();
if trimmed.is_empty() { None } else { Some(trimmed) }
}
Err(_) => None,
}
}
fn read_sysfs_u64(path: &Path) -> Option<u64> {
read_sysfs(path)?.trim().parse::<u64>().ok()
}
fn read_sysfs_u32(path: &Path) -> Option<u32> {
read_sysfs(path)?.trim().parse::<u32>().ok()
}
fn read_sysfs_f64_micro_to_units(path: &Path, unit_divisor: f64) -> Option<f64> {
let raw = read_sysfs_u64(path)?;
Some((raw as f64) / unit_divisor)
}
impl BatteryInfo {
/// Scan `/sys/class/power_supply/` for the first battery device
/// (`type == "Battery"`). Returns `None` if no battery is present
/// (desktop without UPS).
fn find_battery_dir() -> Option<PathBuf> {
let search_root = env::var("RBP_BATTERY_PATH")
.ok()
.unwrap_or_else(|| SYS_POWER_SUPPLY.to_string());
let dir = fs::read_dir(&search_root).ok()?;
for entry in dir.flatten() {
let type_path = entry.path().join("type");
if let Some(t) = read_sysfs(&type_path) {
if t == "Battery" {
return Some(entry.path());
}
}
}
None
}
/// Build a populated `BatteryInfo` from sysfs. Returns an empty
/// `available=false` struct if no battery is detected.
pub fn read() -> Self {
let Some(base) = Self::find_battery_dir() else {
return Self::default();
};
Self {
available: true,
name: read_sysfs(&base.join("name")),
status: read_sysfs(&base.join("status")),
capacity_percent: read_sysfs_u32(&base.join("capacity")),
energy_now_wh: read_sysfs_f64_micro_to_units(&base.join("energy_now"), 1_000_000.0),
energy_full_wh: read_sysfs_f64_micro_to_units(&base.join("energy_full"), 1_000_000.0),
power_now_w: read_sysfs_f64_micro_to_units(&base.join("power_now"), 1_000_000.0),
voltage_now_v: read_sysfs_f64_micro_to_units(&base.join("voltage_now"), 1_000_000.0),
time_to_empty_s: read_sysfs_u64(&base.join("time_to_empty")),
time_to_full_s: read_sysfs_u64(&base.join("time_to_full")),
cycle_count: read_sysfs_u32(&base.join("cycle_count")),
technology: read_sysfs(&base.join("technology")),
model_name: read_sysfs(&base.join("model_name")),
manufacturer: read_sysfs(&base.join("manufacturer")),
serial_number: read_sysfs(&base.join("serial_number")),
}
}
pub fn health_percent(&self) -> Option<u32> {
let now = self.energy_now_wh?;
let full = self.energy_full_wh?;
if full <= 0.0 { None } else { Some(((now / full) * 100.0).round() as u32) }
}
pub fn display(field: &Option<String>) -> &str {
field.as_deref().unwrap_or("?")
}
pub fn display_u32(field: &Option<u32>) -> String {
field.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string())
}
pub fn display_u64(field: &Option<u64>) -> String {
field.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string())
}
pub fn display_f64(field: &Option<f64>) -> String {
field.map(|v| format!("{v:.2}")).unwrap_or_else(|| "?".to_string())
}
pub fn format_duration(secs: u64) -> String {
if secs == 0 { return "?".to_string(); }
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
if h > 0 { format!("{h}h {m}m") } else if m > 0 { format!("{m}m {s}s") } else { format!("{s}s") }
}
}
@@ -35,6 +35,7 @@ use termion::screen::IntoAlternateScreen;
mod acpi;
mod app;
mod battery;
mod bench;
mod config;
mod cpufreq;
@@ -49,7 +50,7 @@ mod theme;
use crate::app::{App, POLL_MS, TabId};
use crate::render::{
render_controls, render_cpu_table, render_header, render_help,
render_battery_panel, render_controls, render_cpu_table, render_header, render_help,
render_info_panel, render_motherboard_panel, render_once,
render_prochot_alert, render_system_panel,
render_tab_bar, snapshot,
@@ -314,6 +315,12 @@ fn main() -> io::Result<()> {
body_area,
);
}
TabId::Battery => {
f.render_widget(
render_battery_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) {
@@ -370,6 +377,7 @@ fn main() -> io::Result<()> {
Key::Char('2') => app.current_tab = app::TabId::System,
Key::Char('3') => app.current_tab = app::TabId::Info,
Key::Char('4') => app.current_tab = app::TabId::Motherboard,
Key::Char('5') => app.current_tab = app::TabId::Battery,
Key::Char('T') => app.current_tab = app.current_tab.next(),
Key::Char('?') => show_help = !show_help,
Key::Char('g') => app.cycle_governor(),
@@ -276,6 +276,7 @@ pub fn render_tab_bar<'a>(app: &'a App) -> Tabs<'a> {
TabId::System,
TabId::Info,
TabId::Motherboard,
TabId::Battery,
]
.iter()
.map(|t| Line::from(t.name()))
@@ -285,6 +286,7 @@ pub fn render_tab_bar<'a>(app: &'a App) -> Tabs<'a> {
TabId::System => 1,
TabId::Info => 2,
TabId::Motherboard => 3,
TabId::Battery => 4,
};
Tabs::new(titles)
.select(selected)
@@ -558,6 +560,95 @@ pub fn render_motherboard_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a
.wrap(Wrap { trim: true })
}
pub fn render_battery_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
let bat = &app.battery;
if !bat.available {
return Paragraph::new(Line::from(
"(no battery detected — /sys/class/power_supply/BAT* not present)".set_style(theme::VALUE_WARM),
))
.block(panel_border(focused, " Battery "))
.wrap(Wrap { trim: true });
}
let mut lines: Vec<Line<'a>> = Vec::new();
lines.push(Line::from("Identity".set_style(theme::LABEL_BOLD)));
lines.push(Line::from(vec![
" Manufacturer: ".set_style(theme::LABEL),
crate::battery::BatteryInfo::display(&bat.manufacturer).set_style(theme::VALUE),
]));
lines.push(Line::from(vec![
" Model: ".set_style(theme::LABEL),
crate::battery::BatteryInfo::display(&bat.model_name).set_style(theme::VALUE),
]));
lines.push(Line::from(vec![
" Technology: ".set_style(theme::LABEL),
crate::battery::BatteryInfo::display(&bat.technology).set_style(theme::VALUE),
]));
lines.push(Line::from(vec![
" Serial: ".set_style(theme::LABEL),
crate::battery::BatteryInfo::display(&bat.serial_number).set_style(theme::VALUE),
]));
lines.push(Line::from(vec![
" Cycles: ".set_style(theme::LABEL),
crate::battery::BatteryInfo::display_u32(&bat.cycle_count).set_style(theme::VALUE),
]));
lines.push(Line::from(""));
lines.push(Line::from("State".set_style(theme::LABEL_BOLD)));
lines.push(Line::from(vec![
" Status: ".set_style(theme::LABEL),
crate::battery::BatteryInfo::display(&bat.status).set_style(theme::VALUE),
]));
lines.push(Line::from(vec![
" Capacity: ".set_style(theme::LABEL),
crate::battery::BatteryInfo::display_u32(&bat.capacity_percent).set_style(theme::VALUE),
"%".set_style(theme::VALUE),
]));
lines.push(Line::from(vec![
" Energy: ".set_style(theme::LABEL),
crate::battery::BatteryInfo::display_f64(&bat.energy_now_wh).set_style(theme::VALUE),
" Wh".set_style(theme::VALUE),
" / ".set_style(theme::VALUE),
crate::battery::BatteryInfo::display_f64(&bat.energy_full_wh).set_style(theme::VALUE),
" Wh".set_style(theme::VALUE),
]));
lines.push(Line::from(vec![
" Health: ".set_style(theme::LABEL),
crate::battery::BatteryInfo::display_u32(&bat.health_percent()).set_style(theme::VALUE),
"%".set_style(theme::VALUE),
" (current charge / full charge)".set_style(theme::VALUE_OFF),
]));
lines.push(Line::from(""));
lines.push(Line::from("Power".set_style(theme::LABEL_BOLD)));
lines.push(Line::from(vec![
" Power: ".set_style(theme::LABEL),
crate::battery::BatteryInfo::display_f64(&bat.power_now_w).set_style(theme::VALUE),
" W".set_style(theme::VALUE),
]));
lines.push(Line::from(vec![
" Voltage: ".set_style(theme::LABEL),
crate::battery::BatteryInfo::display_f64(&bat.voltage_now_v).set_style(theme::VALUE),
" V".set_style(theme::VALUE),
]));
lines.push(Line::from(vec![
" Time to empty: ".set_style(theme::LABEL),
if let Some(s) = bat.time_to_empty_s {
crate::battery::BatteryInfo::format_duration(s).set_style(theme::VALUE)
} else {
"?".set_style(theme::VALUE_OFF)
},
]));
lines.push(Line::from(vec![
" Time to full: ".set_style(theme::LABEL),
if let Some(s) = bat.time_to_full_s {
crate::battery::BatteryInfo::format_duration(s).set_style(theme::VALUE)
} else {
"?".set_style(theme::VALUE_OFF)
},
]));
Paragraph::new(lines)
.block(panel_border(focused, " Battery "))
.wrap(Wrap { trim: true })
}
pub fn render_cpu_table<'a>(
cpus: &'a [CpuRow],
expanded_cpu: Option<u32>,
@@ -922,5 +1013,15 @@ pub fn render_once(app: &App) -> io::Result<()> {
})
.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()));
Ok(())
}