diff --git a/aldermon/Cargo.lock b/aldermon/Cargo.lock
index da3716b..16194ad 100644
--- a/aldermon/Cargo.lock
+++ b/aldermon/Cargo.lock
@@ -2,14 +2,6 @@
# It is not intended for manual editing.
version = 4
-[[package]]
-name = "aldermon"
-version = "0.1.0"
-dependencies = [
- "crossterm",
- "ratatui",
-]
-
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -19,6 +11,14 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "aldermon"
+version = "0.1.0"
+dependencies = [
+ "crossterm",
+ "ratatui",
+]
+
[[package]]
name = "allocator-api2"
version = "0.2.21"
diff --git a/aldermon/Makefile b/aldermon/Makefile
new file mode 100644
index 0000000..958e459
--- /dev/null
+++ b/aldermon/Makefile
@@ -0,0 +1,34 @@
+# aldermon — btop-style Alder Lake monitor. Gentoo-friendly: respects
+# PREFIX, DESTDIR. `make check` runs fmt+clippy+test.
+PREFIX ?= /usr/local
+SYSCONF ?= /etc
+DATADIR ?= /usr/share
+
+BIN := $(DESTDIR)$(PREFIX)/bin/aldermon
+CONF := $(DESTDIR)$(SYSCONF)/aldermon/aldermon.conf
+HELPER := $(DESTDIR)$(DATADIR)/aldermon/powercap-pl-rules.sh
+
+.PHONY: all build check install uninstall clean
+
+all: build
+
+build:
+ cargo build --release
+
+check:
+ cargo fmt --check
+ cargo clippy --all-targets -- -D warnings
+ cargo test
+
+install: build
+ install -Dm755 target/release/aldermon $(BIN)
+ install -Dm644 aldermon.conf $(CONF)
+ install -Dm755 powercap-pl-rules.sh $(HELPER)
+
+uninstall:
+ rm -f $(BIN) $(CONF) $(HELPER)
+ rmdir -p --ignore-fail-on-non-empty $(CONF) 2>/dev/null || true
+ rmdir -p --ignore-fail-on-non-empty $(HELPER) 2>/dev/null || true
+
+clean:
+ cargo clean
diff --git a/aldermon/README.md b/aldermon/README.md
index 49699d0..efa956c 100644
--- a/aldermon/README.md
+++ b/aldermon/README.md
@@ -6,23 +6,27 @@ voltage for overclock-safety work.
## Usage
```sh
-cargo run --release -- --tui # TUI (default workhorse)
-cargo run -- --vid # VID spike tool (debug)
-cargo run # one-shot sensor dump
+aldermon --tui # TUI (default workhorse)
+aldermon --vid # VID spike tool (debug, needs root + msr module)
+aldermon # one-shot sensor dump
+aldermon --help # options
```
-The TUI needs a real terminal (pty); it will not run on redirected output.
+The TUI needs a real terminal; piped/redirected stdout exits with a clear
+error instead of a panic.
-Features: vCore hero panel (limit marker, 5-min peak, auto-scale), board
-rails, package power/temp, per-core frequency bars + temps, E/P-core
-sections, footer max frequency.
+Features: vCore hero panel (limit marker, 5-min peak, auto-scale),
+package power/temp, per-core frequency bars + temps, E/P-core sections,
+braille sub-pixel scrolling graphs, footer max frequency.
## Config
`aldermon.conf` is read from the working directory, then
-`~/.config/aldermon/aldermon.conf`. Simple `KEY = VALUE` lines; `#`
-comments; unknown keys are ignored. All values optional — built-in defaults
-target an ASRock Z690M ITX/ax (nct6798).
+`~/.config/aldermon/aldermon.conf`, then `/etc/aldermon/aldermon.conf`
+(first file found wins; no merging). Simple `KEY = VALUE` lines; `#`
+comments; unknown keys are ignored. All values optional — built-in
+defaults target an ASRock Z690M ITX/ax (nct6798). Numeric keys are
+range-checked; an out-of-range value falls back to the default.
```ini
vcore_limit = 1.403 # safety limit shown as marker on the vCore bar
@@ -39,26 +43,37 @@ power_bar_max = 200.0
temp_min = 0
temp_max = 100
-rail.in0.label = CPU Vcore # hwmon input label
-rail.in4.label = VCCIO?
-rail.in5.hide = true # hide unwired/floating inputs
+poll_ms = 250 # sample/tick period (50..10000)
+graph_secs = 300 # history kept, sizes peaks + auto-scale
+graph_scale_fixed = false # true = pin to the scales above
+graphs = true
+layout = auto # auto | single | dual
```
-Rail labels for this board were confirmed against the BIOS and differential
-stress-ng probing (2026-08-29); see comments in `aldermon.conf`. The only
-remaining guess is in4 = VCCIO.
+## Install
+
+```sh
+make # release build
+make check # fmt + clippy + tests
+doas make install # PREFIX=/usr/local by default
+```
+
+Installs `aldermon` to `$(PREFIX)/bin`, this repo's `aldermon.conf` to
+`/etc/aldermon/aldermon.conf` (system default — override per-user via
+`~/.config/aldermon/`), and the udev helper to `/usr/share/aldermon/`.
+`make uninstall` reverses it (leaves the system conf dir if non-empty).
## Package power (RAPL) — one-time setup
`energy_uj` is root-only on this kernel. To read watts as a normal user,
-install the udev rule once as root:
+install the udev rule once as root (comes from `make install`):
```sh
-doas ./powercap-pl-rules.sh
+doas /usr/share/aldermon/powercap-pl-rules.sh
doas usermod -aG adm $USER # then re-login
```
## Status
-Working: TUI, config, RAPL power, rail map for Z690M ITX/ax. Remaining
+Working: TUI, config (+ system /etc default), RAPL power. Remaining
notes in `to-do.md`.
\ No newline at end of file
diff --git a/aldermon/aldermon.conf b/aldermon/aldermon.conf
index 3d7d112..fadd682 100644
--- a/aldermon/aldermon.conf
+++ b/aldermon/aldermon.conf
@@ -12,23 +12,10 @@
# in3 ~3.33 = +3.3V (BIOS-confirmed 2026-08-29); in8 ~3.18 (3.3 V aux)
# BIOS DRAM 1.37 / 1.2 IMC / 1.8 V / 0.82 PCH: no matching inputs at all
# → those rails are not wired to the nct6798
-# in5/6/10/11/13 floating garbage (unwired) — hidden
+# in5/6/10/11/13 floating garbage (unwired)
+# (Historic rail map — the TUI shows CPU metrics only; in0 stays the
+# vCore source of truth. Lines kept as documentation.)
-rail.in0.label = CPU Vcore
-rail.in1.label = Fixed 1.01
-rail.in2.label = Fixed 3.42
-rail.in3.label = +3.3V
-rail.in4.label = VCCIO?
-rail.in5.hide = true
-rail.in6.hide = true
-rail.in7.label = Fixed 3.42
-rail.in8.label = 3.3V aux
-rail.in9.label = 1.05V PROC
-rail.in10.hide = true
-rail.in11.hide = true
-rail.in12.label = Fixed 1.14
-rail.in13.hide = true
-rail.in14.label = Fixed 1.5V
poll_ms = 250
graph_secs = 300
graph_scale_fixed = true
diff --git a/aldermon/src/config.rs b/aldermon/src/config.rs
index 30b47b9..d2e78c9 100644
--- a/aldermon/src/config.rs
+++ b/aldermon/src/config.rs
@@ -1,19 +1,8 @@
-//! Static board config: rail labels + display limits. Zero deps: simple
-//! `KEY = VALUE` lines, `#` comments, blank lines ignored. Unknown keys are
-//! ignored (forward compatibility). Loaded once at startup.
-
-/// Which hwmon input of the SIO chip maps to what. `(key, label)` pairs;
-/// the key is the sysfs stem, e.g. "in0" → `in0_input`.
-pub struct Rail {
- pub input: String,
- pub label: String,
- /// Inputs not connected on this board report garbage (floating) values;
- /// hide them unless the user maps them.
- pub hide: bool,
-}
+//! Display limits + graph scales. Zero deps: simple `KEY = VALUE` lines,
+//! `#` comments, blank lines ignored. Unknown keys are ignored (forward
+//! compatibility). Loaded once at startup.
pub struct Config {
- pub rails: Vec<Rail>,
pub vcore_limit: f64,
pub temp_warn: f64,
pub temp_crit: f64,
@@ -55,7 +44,6 @@ pub enum Layout {
impl Default for Config {
fn default() -> Self {
Config {
- rails: default_rails(),
vcore_limit: 1.403,
temp_warn: 80.0,
temp_crit: 95.0,
@@ -77,44 +65,19 @@ impl Default for Config {
}
}
-/// Default rail map: ASRock Z690M ITX/ax (nct6798). in0 CONFIRMED Vcore
-/// (shown as the hero meter, not a rail row). v2 view: only CPU-relevant
-/// rails visible by default (in4 VCCIO?, in9 1.05V PROC); the others stay
-/// mapped but hidden — re-enable via `rail.inN.hide = false` in the conf.
-/// Differential stress-ng probing 2026-08-29 + BIOS cross-check; "?" =
-/// evidence-based guess.
-pub fn default_rails() -> Vec<Rail> {
- vec![
- rail("in0", "CPU Vcore", false),
- rail("in1", "Fixed 1.01", true),
- rail("in2", "Fixed 3.42", true),
- rail("in3", "+3.3V", true),
- rail("in4", "VCCIO?", false),
- rail("in5", "", true),
- rail("in6", "", true),
- rail("in7", "Fixed 3.42", true),
- rail("in8", "3.3V aux", true),
- rail("in9", "1.05V PROC", false),
- rail("in10", "", true),
- rail("in11", "", true),
- rail("in12", "Fixed 1.14", true),
- rail("in13", "", true),
- rail("in14", "Fixed 1.5V", true),
- ]
-}
-
-fn rail(input: &str, label: &str, hide: bool) -> Rail {
- Rail {
- input: input.to_string(),
- label: label.to_string(),
- hide,
+/// Parse a numeric value; out-of-range or junk keeps the current value.
+/// Ranges mirror the settings-pane validation so a hand-edited conf can't
+/// produce degenerate scales (empty ranges, zero-width bars).
+fn parse_in(val: &str, cur: f64, range: std::ops::RangeInclusive<f64>) -> f64 {
+ match val.parse::<f64>() {
+ Ok(v) if range.contains(&v) => v,
+ _ => cur,
}
}
-/// Parse config text over a default Config. Lines:
-/// rail.in12.label = VCCSA
-/// rail.in5.hide = true
+/// Parse one `KEY = VALUE` line over a default Config. Lines:
/// vcore_limit = 1.35
+/// poll_ms = 250
fn apply_line(cfg: &mut Config, line: &str) {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
@@ -126,17 +89,19 @@ fn apply_line(cfg: &mut Config, line: &str) {
let key = key.trim();
let val = val.trim();
match key {
- "vcore_limit" => cfg.vcore_limit = val.parse().unwrap_or(cfg.vcore_limit),
- "temp_warn" => cfg.temp_warn = val.parse().unwrap_or(cfg.temp_warn),
- "temp_crit" => cfg.temp_crit = val.parse().unwrap_or(cfg.temp_crit),
- "vcore_bar_max" => cfg.vcore_bar_max = val.parse().unwrap_or(cfg.vcore_bar_max),
- "power_bar_max" => cfg.power_bar_max = val.parse().unwrap_or(cfg.power_bar_max),
- "clock_bar_max" => cfg.clock_bar_max = val.parse().unwrap_or(cfg.clock_bar_max),
- "temp_max" => cfg.temp_max = val.parse().unwrap_or(cfg.temp_max),
- "vcore_min" => cfg.vcore_min = val.parse().unwrap_or(cfg.vcore_min),
- "clock_min" => cfg.clock_min = val.parse().unwrap_or(cfg.clock_min),
- "power_min" => cfg.power_min = val.parse().unwrap_or(cfg.power_min),
- "temp_min" => cfg.temp_min = val.parse().unwrap_or(cfg.temp_min),
+ "vcore_limit" => cfg.vcore_limit = parse_in(val, cfg.vcore_limit, 0.1..=5.0),
+ "temp_warn" => cfg.temp_warn = parse_in(val, cfg.temp_warn, 0.0..=150.0),
+ "temp_crit" => cfg.temp_crit = parse_in(val, cfg.temp_crit, 0.0..=150.0),
+ "vcore_bar_max" => cfg.vcore_bar_max = parse_in(val, cfg.vcore_bar_max, 0.1..=5.0),
+ "power_bar_max" => cfg.power_bar_max = parse_in(val, cfg.power_bar_max, 1.0..=500.0),
+ "clock_bar_max" => {
+ cfg.clock_bar_max = parse_in(val, cfg.clock_bar_max, 500_000.0..=10_000_000.0)
+ }
+ "temp_max" => cfg.temp_max = parse_in(val, cfg.temp_max, 10.0..=150.0),
+ "vcore_min" => cfg.vcore_min = parse_in(val, cfg.vcore_min, 0.0..=5.0),
+ "clock_min" => cfg.clock_min = parse_in(val, cfg.clock_min, 0.0..=10_000_000.0),
+ "power_min" => cfg.power_min = parse_in(val, cfg.power_min, 0.0..=500.0),
+ "temp_min" => cfg.temp_min = parse_in(val, cfg.temp_min, 0.0..=150.0),
"graphs" => cfg.graphs = val == "true",
"poll_ms" => cfg.poll_ms = val.parse().unwrap_or(cfg.poll_ms).max(50),
"graph_secs" => cfg.graph_secs = val.parse().unwrap_or(cfg.graph_secs).max(5),
@@ -148,41 +113,19 @@ fn apply_line(cfg: &mut Config, line: &str) {
_ => Layout::Auto,
}
}
- "rail.hide" => return, // needs input suffix; handled below
_ => {}
}
- if let Some(rest) = key.strip_prefix("rail.") {
- match rest.split_once('.') {
- Some((input, "label")) => set_rail(cfg, input, val, None),
- Some((input, "hide")) => set_rail(cfg, input, "", Some(val == "true")),
- _ => {}
- }
- }
-}
-
-fn set_rail(cfg: &mut Config, input: &str, label: &str, hide: Option<bool>) {
- if let Some(r) = cfg.rails.iter_mut().find(|r| r.input == input) {
- if !label.is_empty() {
- r.label = label.to_string();
- }
- if let Some(h) = hide {
- r.hide = h;
- }
- } else {
- cfg.rails.push(Rail {
- input: input.to_string(),
- label: label.to_string(),
- hide: hide.unwrap_or(false),
- });
- }
}
+/// Search order: ./aldermon.conf (dev tree) → ~/.config/aldermon/
+/// (user override) → /etc/aldermon/ (system default, installed).
+/// First readable file wins; no merging.
pub fn load() -> Config {
let mut cfg = Config::default();
- // Search order: ./aldermon.conf then ~/.config/aldermon/aldermon.conf
let candidates = [
Some(std::path::PathBuf::from("aldermon.conf")),
dirs_home().map(|h| h.join(".config/aldermon/aldermon.conf")),
+ Some(std::path::PathBuf::from("/etc/aldermon/aldermon.conf")),
];
for path in candidates.into_iter().flatten() {
if let Ok(text) = std::fs::read_to_string(&path) {
@@ -234,10 +177,11 @@ pub fn update_conf_text(text: &str, cfg: &Config) -> String {
let key = line.split('#').next().unwrap_or("").trim();
let key = key.split_once('=').map(|(k, _)| k.trim().to_string());
if let Some(k) = key {
- if wanted
- .lines()
- .any(|w| w.split_once('=').map(|(wk, _)| wk.trim() == k).unwrap_or(false))
- && !seen.contains(&k)
+ if wanted.lines().any(|w| {
+ w.split_once('=')
+ .map(|(wk, _)| wk.trim() == k)
+ .unwrap_or(false)
+ }) && !seen.contains(&k)
{
// First occurrence of a managed key: emit the fresh block
// and mark every block key seen (the block covers them all).
@@ -276,30 +220,25 @@ mod tests {
use super::*;
#[test]
- fn parses_rail_label() {
+ fn tolerates_junk() {
let mut cfg = Config::default();
- apply_line(&mut cfg, "rail.in12.label = CPU Vccio # note");
- assert_eq!(cfg.rails.iter().find(|r| r.input == "in12").unwrap().label, "CPU Vccio");
+ apply_line(&mut cfg, "not a config line");
+ apply_line(&mut cfg, "unknown_key = 42");
+ apply_line(&mut cfg, "vcore_limit = notanumber");
+ assert_eq!(cfg.vcore_limit, 1.403);
}
#[test]
- fn parses_hide_and_limits() {
+ fn rejects_out_of_range() {
let mut cfg = Config::default();
- apply_line(&mut cfg, "rail.in5.hide = true");
+ apply_line(&mut cfg, "vcore_limit = 99"); // above the 5 V sanity cap
+ assert_eq!(cfg.vcore_limit, 1.403);
+ apply_line(&mut cfg, "clock_bar_max = 0"); // degenerate scale
+ assert_eq!(cfg.clock_bar_max, 4_500_000.0);
apply_line(&mut cfg, "vcore_limit = 1.35");
- assert!(cfg.rails.iter().find(|r| r.input == "in5").unwrap().hide);
assert_eq!(cfg.vcore_limit, 1.35);
}
- #[test]
- fn tolerates_junk() {
- let mut cfg = Config::default();
- apply_line(&mut cfg, "not a config line");
- apply_line(&mut cfg, "unknown_key = 42");
- apply_line(&mut cfg, "vcore_limit = notanumber");
- assert_eq!(cfg.vcore_limit, 1.403);
- }
-
#[test]
fn parses_layout_and_graphs() {
let mut cfg = Config::default();
@@ -311,15 +250,6 @@ mod tests {
assert_eq!(cfg.layout, Layout::Auto);
}
- #[test]
- fn non_cpu_rails_hidden_by_default() {
- let cfg = Config::default();
- let hidden: Vec<&str> = cfg.rails.iter().filter(|r| r.hide).map(|r| r.input.as_str()).collect();
- assert_eq!(hidden, ["in1", "in2", "in3", "in5", "in6", "in7", "in8", "in10", "in11", "in12", "in13", "in14"]);
- let shown: Vec<&str> = cfg.rails.iter().filter(|r| !r.hide && r.input != "in0").map(|r| r.input.as_str()).collect();
- assert_eq!(shown, ["in4", "in9"]);
- }
-
#[test]
fn parses_poll_and_graph_window() {
let mut cfg = Config::default();
@@ -334,17 +264,19 @@ mod tests {
#[test]
fn updates_conf_in_place() {
let text = "# comment\npoll_ms = 250\ngraph_secs = 120\nrail.in0.label = CPU Vcore\ngraphs = true\n";
- let mut cfg = Config::default();
- cfg.poll_ms = 500;
- cfg.graph_secs = 300;
- cfg.graphs = false;
+ let cfg = Config {
+ poll_ms: 500,
+ graph_secs: 300,
+ graphs: false,
+ ..Default::default()
+ };
let out = update_conf_text(text, &cfg);
assert!(out.contains("poll_ms = 500"));
assert!(out.contains("graph_secs = 300"));
assert!(out.contains("graphs = false"));
assert!(out.contains("rail.in0.label = CPU Vcore")); // untouched
assert!(out.starts_with("# comment")); // untouched
- // Each managed key appears exactly once
+ // Each managed key appears exactly once
assert_eq!(out.matches("poll_ms").count(), 1);
assert_eq!(out.matches("graphs").count(), 1);
}
diff --git a/aldermon/src/main.rs b/aldermon/src/main.rs
index 7a8b439..d8da846 100644
--- a/aldermon/src/main.rs
+++ b/aldermon/src/main.rs
@@ -1,25 +1,49 @@
-//! aldermon — Alder Lake monitor.
+//! aldermon — Alder Lake hardware monitor (i5-12600KF / Z690M ITX/ax).
//!
-//! Currently a dump tool: prints a one-shot sensor snapshot to stdout. The
-//! TUI consumes the same `sensors` layer once the vCore source spike is done.
+//! Modes: no args = one-shot sensor dump; `--tui` = live TUI (vCore hero,
+//! per-core bars, braille graphs, F2 settings); `--vid` = root-only VID vs
+//! in0 cross-check (debug spike, kept as a tool).
mod config;
mod plot;
mod sensors;
mod ui;
+use std::io::IsTerminal;
use std::thread::sleep;
use std::time::{Duration, Instant};
use sensors::{cpu_frequencies, discover_chips, rapl_domains, rapl_energy_uj};
+const USAGE: &str = "\
+usage: aldermon [OPTION]
+
+ (no option) one-shot sensor dump to stdout
+ --tui live monitoring TUI (q/Esc quit, F2 settings)
+ --vid VID vs SIO in0 cross-check (needs root + msr module)
+ --help show this help";
+
fn main() {
let args: Vec<String> = std::env::args().collect();
+ for a in &args[1..] {
+ if a.starts_with('-') && !matches!(a.as_str(), "--tui" | "--vid" | "--help") {
+ eprintln!("aldermon: unknown option '{a}'\n{USAGE}");
+ std::process::exit(2);
+ }
+ }
+ if args.iter().any(|a| a == "--help") {
+ println!("{USAGE}");
+ return;
+ }
if args.iter().any(|a| a == "--vid") {
vid_spike();
return;
}
if args.iter().any(|a| a == "--tui") {
+ if !std::io::stdout().is_terminal() {
+ eprintln!("aldermon: --tui needs a real terminal (stdout is not a tty)");
+ std::process::exit(1);
+ }
ui::run_tui();
return;
}
@@ -77,6 +101,6 @@ fn vid_spike() {
None => println!(" cpu{:>2} VID: unreadable", cpu),
}
}
- std::thread::sleep(std::time::Duration::from_millis(400));
+ sleep(Duration::from_millis(400));
}
}
diff --git a/aldermon/src/plot.rs b/aldermon/src/plot.rs
index 8933266..4ebe745 100644
--- a/aldermon/src/plot.rs
+++ b/aldermon/src/plot.rs
@@ -5,19 +5,20 @@
//! resolution a TUI offers. No hysteresis, no EMA: raw samples map
//! directly to sub-pixel positions so small jitters read.
//!
-//! Window mapping: `window_ticks` (= graph_secs / poll_ms) samples span
-//! the full sub-col width, right-pinned (newest at the right edge). When
-//! the window holds more samples than sub-cols, each sub-col shows the
-//! MAX of its bucket — spikes survive decimation. When the window is
-//! shorter than the width, each sample stretches across multiple sub-cols
-//! and the left side stays blank until history accrues. Consecutive
+//! Window mapping (btop rule): 1:1 — one sample per sub-col, newest at
+//! the right edge, NO decimation and NO hysteresis, so middle-of-trace
+//! dots never change as the window scrolls (only the right edge wiggles
+//! as new samples arrive). The visible span is therefore `sub_w ×
+//! poll_ms` (sub_w = 2 × inner width); `graph_secs` sizes the ring for
+//! peaks/scale only, it does NOT stretch the view. History shorter than
+//! the width leaves the left side blank until it accrues. Consecutive
//! sub-cols are gap-filled vertically so steps read as a connected line.
use ratatui::{
- Frame,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
+ Frame,
};
/// Ring of samples, oldest first. Capacity is sized for the configured
@@ -74,9 +75,9 @@ impl Ring {
}
}
-/// Plain rounding map to cell rows (used by tests; the braille renderer
-/// does its own sub-pixel mapping).
-#[allow(dead_code)]
+/// Plain rounding map to cell rows (test helper for the value→row math;
+/// the braille renderer does its own sub-pixel mapping).
+#[cfg(test)]
fn levels(samples: &[f64], min: f64, max: f64, rows: usize) -> Vec<usize> {
// Row 0 = top of the plot = max value.
let span = max - min;
@@ -147,7 +148,6 @@ pub fn render(
f: &mut Frame,
area: Rect,
ring: &Ring,
- _window_ticks: usize,
min: f64,
max: f64,
color: Color,
@@ -221,7 +221,6 @@ pub fn render(
prev_row = Some(sr);
}
- // Marker row: red '┄' on blank cells (first-blank-wins, as before).
// Marker row: red '┄' on blank cells, in the caller's [0, rows-1]
// cell-row (no padding — the caller's scale headroom keeps it clear
// of the borders in auto mode; in fixed mode a limit at the bound
@@ -235,7 +234,10 @@ pub fn render(
let mut spans: Vec<Span> = Vec::with_capacity(cols);
for &b in bits[r].iter().take(cols) {
if b != 0 {
- spans.push(Span::styled(char::from_u32(0x2800 + b as u32).unwrap().to_string(), st));
+ spans.push(Span::styled(
+ char::from_u32(0x2800 + b as u32).unwrap().to_string(),
+ st,
+ ));
} else if marker_cell_row == Some(r) {
spans.push(Span::styled("┄".to_string(), marker_st));
} else {
@@ -301,8 +303,16 @@ mod tests {
let out = map_to_subcols(&s, 8);
assert_eq!(
out,
- vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0),
- Some(5.0), Some(6.0), Some(7.0), Some(8.0)]
+ vec![
+ Some(1.0),
+ Some(2.0),
+ Some(3.0),
+ Some(4.0),
+ Some(5.0),
+ Some(6.0),
+ Some(7.0),
+ Some(8.0)
+ ]
);
}
@@ -328,7 +338,14 @@ mod tests {
let out = map_to_subcols(&s, 6);
assert_eq!(
out,
- vec![Some(5.0), Some(6.0), Some(7.0), Some(8.0), Some(9.0), Some(10.0)]
+ vec![
+ Some(5.0),
+ Some(6.0),
+ Some(7.0),
+ Some(8.0),
+ Some(9.0),
+ Some(10.0)
+ ]
);
}
@@ -350,8 +367,9 @@ mod tests {
assert_eq!(braille_dot(4, 0), 0x01);
// Cross-check: a full column (col 0, all 4 rows) = 0x01|0x02|0x04|
// 0x40 = 0x47 = "⡇"; btop's braille_up table row 4 col 0 is "⡇".
- let full_col0 = braille_dot(0, 0) | braille_dot(1, 0) | braille_dot(2, 0) | braille_dot(3, 0);
+ let full_col0 =
+ braille_dot(0, 0) | braille_dot(1, 0) | braille_dot(2, 0) | braille_dot(3, 0);
assert_eq!(full_col0, 0x47);
assert_eq!(char::from_u32(0x2800 + full_col0 as u32), Some('⡇'));
}
-}
\ No newline at end of file
+}
diff --git a/aldermon/src/sensors.rs b/aldermon/src/sensors.rs
index 29ad604..c4f962d 100644
--- a/aldermon/src/sensors.rs
+++ b/aldermon/src/sensors.rs
@@ -211,11 +211,13 @@ pub fn sio_vcore() -> Option<f64> {
sio_input("in0")
}
-/// Any SIO voltage input by sysfs stem ("in0", "in12", …).
+/// Any SIO voltage input by sysfs stem ("in0", "in12", …), in volts.
+/// Reads exactly `{stem}_input` after resolving the chip — not via
+/// `voltages()`, whose label preference could shadow the stem name if a
+/// future kernel ships `in0_label` (silently breaking the vCore read).
pub fn sio_input(input: &str) -> Option<f64> {
- let chip = discover_chips().into_iter().find(|c| c.name.starts_with("nct"))?;
- voltages(&chip)
+ let chip = discover_chips()
.into_iter()
- .find(|(label, _)| label == input)
- .map(|(_, v)| v)
+ .find(|c| c.name.starts_with("nct"))?;
+ read_int(&chip.path.join(format!("{input}_input"))).map(|mv| mv as f64 / 1000.0)
}
diff --git a/aldermon/src/ui.rs b/aldermon/src/ui.rs
index 9c4a6eb..54e2603 100644
--- a/aldermon/src/ui.rs
+++ b/aldermon/src/ui.rs
@@ -6,10 +6,10 @@ use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
- Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
+ Frame,
};
use crate::config;
@@ -91,7 +91,7 @@ fn setting_value(app: &App, row: usize) -> String {
1 => {
// Minutes when the window is a whole minute or more.
let s = app.cfg.graph_secs;
- if s >= 60 && s % 60 == 0 {
+ if s >= 60 && s.is_multiple_of(60) {
format!("{} m", s / 60)
} else {
format!("{} s", s)
@@ -136,11 +136,18 @@ fn parse_window_secs(raw: &str) -> Result<u64, String> {
}
if let Some((m, s)) = raw.split_once('m') {
let s = s.strip_suffix('s').unwrap_or(s);
- let m: u64 = m.trim().parse().map_err(|_| format!("'{raw}' is not a number"))?;
- let s: u64 = s.trim().parse().map_err(|_| format!("'{raw}' is not a number"))?;
+ let m: u64 = m
+ .trim()
+ .parse()
+ .map_err(|_| format!("'{raw}' is not a number"))?;
+ let s: u64 = s
+ .trim()
+ .parse()
+ .map_err(|_| format!("'{raw}' is not a number"))?;
return Ok(m * 60 + s);
}
- raw.parse::<u64>().map_err(|_| format!("'{raw}' is not a number"))
+ raw.parse::<u64>()
+ .map_err(|_| format!("'{raw}' is not a number"))
}
fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
@@ -153,6 +160,8 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
return Err("poll_ms must be 50..10000".into());
}
app.cfg.poll_ms = v;
+ // Ring capacity is derived from graph_secs / poll_ms.
+ app.resize_graphs();
}
1 => {
let v = parse_window_secs(raw)?;
@@ -160,10 +169,7 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
return Err("window must be 5s..60m".into());
}
app.cfg.graph_secs = v;
- app.vcore_graph = Ring::new(ring_capacity(&app.cfg));
- app.watts_graph = Ring::new(ring_capacity(&app.cfg));
- app.temp_graph = Ring::new(ring_capacity(&app.cfg));
- app.clock_graph = Ring::new(ring_capacity(&app.cfg));
+ app.resize_graphs();
}
2 => {
// Scale: toggle auto/fixed. Bare Enter toggles; also accept
@@ -175,20 +181,18 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
_ => return Err("use auto|fixed (or Enter to toggle)".into()),
}
}
- 3 => parse_scale(raw, 0.0..=app.cfg.vcore_bar_max, "V")
- .map(|v| app.cfg.vcore_min = v)?,
+ 3 => parse_scale(raw, 0.0..=app.cfg.vcore_bar_max, "V").map(|v| app.cfg.vcore_min = v)?,
4 => parse_scale(raw, app.cfg.vcore_min.max(0.1)..=5.0, "V")
.map(|v| app.cfg.vcore_bar_max = v)?,
5 => parse_scale(raw, 0.0..=app.cfg.clock_bar_max / 1_000_000.0, "GHz")
.map(|v| app.cfg.clock_min = v * 1_000_000.0)?,
- 6 => parse_scale(raw, 0.5..=10.0, "GHz")
- .map(|v| app.cfg.clock_bar_max = v * 1_000_000.0)?,
- 7 => parse_scale(raw, 0.0..=app.cfg.power_bar_max, "W")
- .map(|v| app.cfg.power_min = v)?,
+ 6 => {
+ parse_scale(raw, 0.5..=10.0, "GHz").map(|v| app.cfg.clock_bar_max = v * 1_000_000.0)?
+ }
+ 7 => parse_scale(raw, 0.0..=app.cfg.power_bar_max, "W").map(|v| app.cfg.power_min = v)?,
8 => parse_scale(raw, app.cfg.power_min.max(1.0)..=500.0, "W")
.map(|v| app.cfg.power_bar_max = v)?,
- 9 => parse_scale(raw, 0.0..=app.cfg.temp_max, "°C")
- .map(|v| app.cfg.temp_min = v)?,
+ 9 => parse_scale(raw, 0.0..=app.cfg.temp_max, "°C").map(|v| app.cfg.temp_min = v)?,
_ => parse_scale(raw, app.cfg.temp_min.max(10.0)..=150.0, "°C")
.map(|v| app.cfg.temp_max = v)?,
}
@@ -197,7 +201,10 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
/// Parse a scale bound typed in the settings pane; must land in `range`.
fn parse_scale(raw: &str, range: std::ops::RangeInclusive<f64>, unit: &str) -> Result<f64, String> {
- let v: f64 = raw.trim().parse().map_err(|_| format!("'{raw}' is not a number"))?;
+ let v: f64 = raw
+ .trim()
+ .parse()
+ .map_err(|_| format!("'{raw}' is not a number"))?;
if !range.contains(&v) {
return Err(format!("out of range ({unit})"));
}
@@ -260,6 +267,17 @@ impl App {
}
}
+ /// Rebuild the graph rings at the capacity implied by the current cfg.
+ /// Called whenever poll_ms or graph_secs change — the old sampling grid
+ /// is stale at the new period, so history is dropped.
+ fn resize_graphs(&mut self) {
+ let cap = ring_capacity(&self.cfg);
+ self.vcore_graph = Ring::new(cap);
+ self.watts_graph = Ring::new(cap);
+ self.temp_graph = Ring::new(cap);
+ self.clock_graph = Ring::new(cap);
+ }
+
fn poll(&mut self) {
self.vcore = sensors::sio_vcore();
if let Some(v) = self.vcore {
@@ -309,9 +327,7 @@ impl App {
let e_core = cpus.first().is_some_and(|c| *c >= 12);
let freq_khz = cpus
.iter()
- .filter_map(|c| {
- freqs.iter().find(|(cpu, _)| cpu == c).map(|(_, k)| *k)
- })
+ .filter_map(|c| freqs.iter().find(|(cpu, _)| cpu == c).map(|(_, k)| *k))
.max();
let freq_max_khz = cpus
.iter()
@@ -359,8 +375,7 @@ impl App {
self.package_watts = Some(w);
self.watts_graph.push(w);
if w.is_finite() {
- self.watts_all_time_peak =
- self.watts_all_time_peak.max(w);
+ self.watts_all_time_peak = self.watts_all_time_peak.max(w);
}
}
Some((e, Instant::now()))
@@ -435,8 +450,14 @@ fn meter_line(
// Value text right-aligned inside the bar (htop pattern); bold default fg.
let text_len = value_text.chars().count();
let text: Vec<char> = if text_len >= inner {
- value_text.chars().rev().take(inner).collect::<Vec<_>>()
- .into_iter().rev().collect()
+ value_text
+ .chars()
+ .rev()
+ .take(inner)
+ .collect::<Vec<_>>()
+ .into_iter()
+ .rev()
+ .collect()
} else {
let pad = inner - text_len;
std::iter::repeat_n(' ', pad)
@@ -499,8 +520,7 @@ fn core_section_lines(title: &str, cores: &[CoreRow], app: &App, width: u16) ->
// 800 MHz idle floor sits at 0% and turbo pegs 100%.
let ratio = match (core.freq_khz, core.freq_max_khz) {
(Some(f), max) if max > core.freq_min_khz => Some(
- ((f as f64 - core.freq_min_khz as f64)
- / (max - core.freq_min_khz) as f64)
+ ((f as f64 - core.freq_min_khz as f64) / (max - core.freq_min_khz) as f64)
.clamp(0.0, 1.0),
),
_ => None,
@@ -523,16 +543,33 @@ fn render_lines(f: &mut Frame, area: Rect, lines: &[Line<'static>]) {
}
f.render_widget(
ratatui::text::Text::from(line.clone()),
- Rect { x: area.x, y: area.y + i as u16, width: area.width, height: 1 },
+ Rect {
+ x: area.x,
+ y: area.y + i as u16,
+ width: area.width,
+ height: 1,
+ },
);
}
}
-/// One stacked plot: (label, live text, peak text, ring, axis min/max
-/// [RAW, for labels], trace min/max [PADDED, for render], line color,
-/// optional marker row, axis display divisor — clock shows GHz on the
-/// axis but stores kHz).
-type PlotSpec<'a> = (&'a str, String, String, &'a Ring, f64, f64, f64, f64, Color, Option<u16>, f64);
+/// One stacked plot. `axis_*` bounds are RAW (shown in the axis labels —
+/// the user's conf/window values); `trace_*` bounds are PADDED (what the
+/// renderer maps the trace over). `axis_div` is the display divisor —
+/// clock shows GHz on the axis but stores kHz.
+struct PlotSpec<'a> {
+ label: &'a str,
+ live: String,
+ peak: String,
+ ring: &'a Ring,
+ axis_min: f64,
+ axis_max: f64,
+ trace_min: f64,
+ trace_max: f64,
+ color: Color,
+ marker: Option<u16>,
+ axis_div: f64,
+}
pub fn draw(f: &mut Frame, app: &App) {
let area = f.area();
@@ -557,7 +594,10 @@ pub fn draw(f: &mut Frame, app: &App) {
(area.height.saturating_sub(3 + footer_h), 0)
} else if area.height >= cores_full + plots_min + footer_h {
let leftover = area.height - cores_full - footer_h - 1;
- (cores_full, (leftover / 4).saturating_sub(3).max(GRAPH_MIN_ROWS))
+ (
+ cores_full,
+ (leftover / 4).saturating_sub(3).max(GRAPH_MIN_ROWS),
+ )
} else if area.height >= compact_h + plots_min + footer_h {
(compact_h, GRAPH_MIN_ROWS)
} else {
@@ -574,7 +614,10 @@ pub fn draw(f: &mut Frame, app: &App) {
2 // pkg power + pkg temp meter rows
};
let bottom_h = bottom_h + 1; // blank separator above footer
- let cores_area = Rect { height: cores_h, ..area };
+ let cores_area = Rect {
+ height: cores_h,
+ ..area
+ };
let bottom_y = area.y + cores_h;
let footer_y = area.y + area.height.saturating_sub(footer_h);
@@ -583,11 +626,7 @@ pub fn draw(f: &mut Frame, app: &App) {
// that class's cores) instead of clipped per-core rows.
let p: Vec<CoreRow> = app.cores.iter().filter(|c| !c.e_core).cloned().collect();
let e: Vec<CoreRow> = app.cores.iter().filter(|c| c.e_core).cloned().collect();
- let (col_w, used) = if dual {
- ((w / 2).max(1), 2)
- } else {
- (w, 1)
- };
+ let (col_w, n_cols) = if dual { ((w / 2).max(1), 2) } else { (w, 1) };
if graphs_on && cores_h <= compact_h {
// Compact: "P-cores"/"E-cores" meters show the busiest core of each
// class (freq max; temp max for the safety color).
@@ -606,28 +645,62 @@ pub fn draw(f: &mut Frame, app: &App) {
let fmax = cores.iter().map(|c| c.freq_max_khz).max().unwrap_or(0);
let fmin = cores.iter().map(|c| c.freq_min_khz).max().unwrap_or(0);
let ratio = match freq {
- Some(fq) if fmax > fmin => Some(
- ((fq as f64 - fmin as f64) / (fmax - fmin) as f64).clamp(0.0, 1.0),
- ),
+ Some(fq) if fmax > fmin => {
+ Some(((fq as f64 - fmin as f64) / (fmax - fmin) as f64).clamp(0.0, 1.0))
+ }
_ => None,
};
let color = match temp {
Some(t) => temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit),
None => Color::DarkGray,
};
- render_lines(f, rect, &[meter_line(title, &text, ratio, color, rect.width, None)]);
+ render_lines(
+ f,
+ rect,
+ &[meter_line(title, &text, ratio, color, rect.width, None)],
+ );
};
- compact("P-cores", &p, Rect { width: col_w, ..cores_area });
- if used == 2 {
- compact("E-cores", &e, Rect { x: area.x + col_w, width: w - col_w, ..cores_area });
+ compact(
+ "P-cores",
+ &p,
+ Rect {
+ width: col_w,
+ ..cores_area
+ },
+ );
+ if n_cols == 2 {
+ compact(
+ "E-cores",
+ &e,
+ Rect {
+ x: area.x + col_w,
+ width: w - col_w,
+ ..cores_area
+ },
+ );
}
} else {
let sections = [
- ("P-cores", p, Rect { width: col_w, ..cores_area }),
- ("E-cores", e, Rect { x: area.x + col_w, width: w - col_w, ..cores_area }),
+ (
+ "P-cores",
+ p,
+ Rect {
+ width: col_w,
+ ..cores_area
+ },
+ ),
+ (
+ "E-cores",
+ e,
+ Rect {
+ x: area.x + col_w,
+ width: w - col_w,
+ ..cores_area
+ },
+ ),
];
for (i, (title, cores, rect)) in sections.into_iter().enumerate() {
- if i >= used || rect.width == 0 {
+ if i >= n_cols || rect.width == 0 {
continue;
}
let lines = core_section_lines(title, &cores, app, rect.width);
@@ -636,7 +709,11 @@ pub fn draw(f: &mut Frame, app: &App) {
}
// ---- package zone (bottom) ----
- let bottom_area = Rect { y: bottom_y, height: bottom_h, ..area };
+ let bottom_area = Rect {
+ y: bottom_y,
+ height: bottom_h,
+ ..area
+ };
let vc_max = dynamic_max(app.vcore_all_time_peak, app.cfg.vcore_bar_max, SCALE_MARGIN);
let watt_max = dynamic_max(app.watts_all_time_peak, app.cfg.power_bar_max, SCALE_MARGIN);
let vc_color = match app.vcore {
@@ -698,21 +775,25 @@ pub fn draw(f: &mut Frame, app: &App) {
// Raw bounds: fixed = conf min/max, auto = window min/max +
// SCALE_MARGIN. Used for the axis labels so they show the user's
// conf values (not the padded trace bounds).
- let (vc_lo, vc_hi, ck_lo, ck_hi, pw_lo, pw_hi, tp_lo, tp_hi) =
- if app.cfg.graph_scale_fixed {
- (
- app.cfg.vcore_min, app.cfg.vcore_bar_max,
- app.cfg.clock_min, app.cfg.clock_bar_max,
- app.cfg.power_min, app.cfg.power_bar_max,
- app.cfg.temp_min, app.cfg.temp_max,
- )
- } else {
- let vc = (ring_min(&app.vcore_graph), ring_max(&app.vcore_graph, 0.0));
- let ck = (ring_min(&app.clock_graph), ring_max(&app.clock_graph, 0.0));
- let pw = (ring_min(&app.watts_graph), ring_max(&app.watts_graph, 0.0));
- let tp = (ring_min(&app.temp_graph), ring_max(&app.temp_graph, 0.0));
- (vc.0, vc.1, ck.0, ck.1, pw.0, pw.1, tp.0, tp.1)
- };
+ let (vc_lo, vc_hi, ck_lo, ck_hi, pw_lo, pw_hi, tp_lo, tp_hi) = if app.cfg.graph_scale_fixed
+ {
+ (
+ app.cfg.vcore_min,
+ app.cfg.vcore_bar_max,
+ app.cfg.clock_min,
+ app.cfg.clock_bar_max,
+ app.cfg.power_min,
+ app.cfg.power_bar_max,
+ app.cfg.temp_min,
+ app.cfg.temp_max,
+ )
+ } else {
+ let vc = (ring_min(&app.vcore_graph), ring_max(&app.vcore_graph, 0.0));
+ let ck = (ring_min(&app.clock_graph), ring_max(&app.clock_graph, 0.0));
+ let pw = (ring_min(&app.watts_graph), ring_max(&app.watts_graph, 0.0));
+ let tp = (ring_min(&app.temp_graph), ring_max(&app.temp_graph, 0.0));
+ (vc.0, vc.1, ck.0, ck.1, pw.0, pw.1, tp.0, tp.1)
+ };
// Padded bounds for the trace mapping: ~1/4 interior row top and
// bottom (plus a small per-metric floor) so the trace stays just
// clear of the box borders in both fixed and auto mode. Applied
@@ -727,17 +808,84 @@ pub fn draw(f: &mut Frame, app: &App) {
let (p_tp_lo, p_tp_hi) = pad(tp_lo, tp_hi, 1.0);
// 4 boxed plots stacked top→bottom, sharing one time axis.
// Cascade: vCore → Peak clock → Pkg power → Pkg temp (user order).
+ // vCore limit marker cell row (over the PADDED bounds, clamped so
+ // an out-of-scale limit pins to the box edge).
+ let vc_marker = ((1.0
+ - ((app.cfg.vcore_limit - p_vc_lo) / (p_vc_hi - p_vc_lo)).clamp(0.0, 1.0))
+ * (graph_rows - 1) as f64)
+ .round() as u16;
+ let temp_now = app.package_temp.map_or(Color::DarkGray, |t| {
+ temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)
+ });
let plots: [PlotSpec; 4] = [
- ("vCore", hero_text.clone(), hero_peak, &app.vcore_graph, vc_lo, vc_hi, p_vc_lo, p_vc_hi, vc_color,
- Some(((1.0 - ((app.cfg.vcore_limit - p_vc_lo) / (p_vc_hi - p_vc_lo)).clamp(0.0, 1.0))
- * (graph_rows - 1) as f64).round() as u16), 1.0),
- ("Clock", clock_text, clock_peak, &app.clock_graph, ck_lo, ck_hi, p_ck_lo, p_ck_hi, Color::Green, None, 1_000_000.0),
- ("Power", fmt_opt_watts(app.package_watts), watts_peak, &app.watts_graph, pw_lo, pw_hi, p_pw_lo, p_pw_hi, Color::Blue, None, 1.0),
- ("Temp", fmt_opt_temp(app.package_temp), temp_peak, &app.temp_graph, tp_lo, tp_hi, p_tp_lo, p_tp_hi,
- app.package_temp.map_or(Color::DarkGray, |t| temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)), None, 1.0),
+ PlotSpec {
+ label: "vCore",
+ live: hero_text.clone(),
+ peak: hero_peak,
+ ring: &app.vcore_graph,
+ axis_min: vc_lo,
+ axis_max: vc_hi,
+ trace_min: p_vc_lo,
+ trace_max: p_vc_hi,
+ color: vc_color,
+ marker: Some(vc_marker),
+ axis_div: 1.0,
+ },
+ PlotSpec {
+ label: "Clock",
+ live: clock_text,
+ peak: clock_peak,
+ ring: &app.clock_graph,
+ axis_min: ck_lo,
+ axis_max: ck_hi,
+ trace_min: p_ck_lo,
+ trace_max: p_ck_hi,
+ color: Color::Green,
+ marker: None,
+ axis_div: 1_000_000.0,
+ },
+ PlotSpec {
+ label: "Power",
+ live: fmt_opt_watts(app.package_watts),
+ peak: watts_peak,
+ ring: &app.watts_graph,
+ axis_min: pw_lo,
+ axis_max: pw_hi,
+ trace_min: p_pw_lo,
+ trace_max: p_pw_hi,
+ color: Color::Blue,
+ marker: None,
+ axis_div: 1.0,
+ },
+ PlotSpec {
+ label: "Temp",
+ live: fmt_opt_temp(app.package_temp),
+ peak: temp_peak,
+ ring: &app.temp_graph,
+ axis_min: tp_lo,
+ axis_max: tp_hi,
+ trace_min: p_tp_lo,
+ trace_max: p_tp_hi,
+ color: temp_now,
+ marker: None,
+ axis_div: 1.0,
+ },
];
let mut y = bottom_y;
- for (label, text, peak, ring, axis_min, axis_max, min, max, color, marker, axis_div) in plots {
+ for PlotSpec {
+ label,
+ live,
+ peak,
+ ring,
+ axis_min,
+ axis_max,
+ trace_min,
+ trace_max,
+ color,
+ marker,
+ axis_div,
+ } in plots
+ {
let axis_max = plot::fmt_axis(axis_max / axis_div);
let axis_min = if axis_div > 1.0 {
// GHz axis: always show decimals so a near-zero min isn't "0".
@@ -761,14 +909,24 @@ pub fn draw(f: &mut Frame, app: &App) {
// ratatui's line diff drops separator spans in narrow rects.
let key = Line::from(vec![
Span::styled(format!("{label} "), Style::default().fg(Color::Cyan)),
- Span::styled(text.clone(), Style::default().add_modifier(Modifier::BOLD)),
+ Span::styled(live, Style::default().add_modifier(Modifier::BOLD)),
]);
f.render_widget(
ratatui::text::Text::from(key),
- Rect { x: area.x, y, width: w, height: 1 },
+ Rect {
+ x: area.x,
+ y,
+ width: w,
+ height: 1,
+ },
);
- let box_rect = Rect { x: box_x, y: y + 1, width: box_w, height: box_h };
+ let box_rect = Rect {
+ x: box_x,
+ y: y + 1,
+ width: box_w,
+ height: box_h,
+ };
f.render_widget(
ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
@@ -785,47 +943,90 @@ pub fn draw(f: &mut Frame, app: &App) {
if max_w + 2 <= box_w {
f.render_widget(
ratatui::text::Text::from(Line::from(Span::styled(axis_max.clone(), axis_st))),
- Rect { x: box_x + 2, y: box_rect.y, width: max_w, height: 1 },
+ Rect {
+ x: box_x + 2,
+ y: box_rect.y,
+ width: max_w,
+ height: 1,
+ },
);
}
if min_w + 2 <= box_w {
f.render_widget(
ratatui::text::Text::from(Line::from(Span::styled(axis_min.clone(), axis_st))),
- Rect { x: box_x + 2, y: box_rect.y + box_h - 1, width: min_w, height: 1 },
+ Rect {
+ x: box_x + 2,
+ y: box_rect.y + box_h - 1,
+ width: min_w,
+ height: 1,
+ },
);
}
- let used = plot::render(
+ let trace_cols = plot::render(
f,
- Rect { x: box_rect.x + 1, y: box_rect.y + 1, width: inner_w, height: graph_rows },
- ring, 0, min, max, color, marker,
+ Rect {
+ x: box_rect.x + 1,
+ y: box_rect.y + 1,
+ width: inner_w,
+ height: graph_rows,
+ },
+ ring,
+ trace_min,
+ trace_max,
+ color,
+ marker,
);
if !peak.is_empty() {
let peak_w = peak.chars().count() as u16;
- let trace_right = box_rect.x + 1 + used.max(1);
- let peak_x = trace_right
- .saturating_sub(peak_w)
- .clamp(area.x + KEY_RESERVE, box_rect.x + box_w - peak_w.min(box_w));
+ let trace_right = box_rect.x + 1 + trace_cols.max(1);
+ // Never call clamp with min > max (it panics): order the
+ // bounds with saturating math first.
+ let hi = box_rect.x + box_w.saturating_sub(peak_w);
+ let lo = (area.x + KEY_RESERVE).min(hi);
+ let peak_x = trace_right.saturating_sub(peak_w).max(lo).min(hi);
f.render_widget(
ratatui::text::Text::from(Line::from(Span::styled(
peak,
Style::default().fg(Color::DarkGray),
))),
- Rect { x: peak_x, y, width: peak_w, height: 1 },
+ Rect {
+ x: peak_x,
+ y,
+ width: peak_w,
+ height: 1,
+ },
);
}
y += plot_h;
}
} else if !app.settings_open {
- let mut lines = vec![
- meter_line("vCore", &hero_text, app.vcore.map(|v| v / vc_max), vc_color, w,
- Some(app.cfg.vcore_limit / vc_max)),
- ];
+ let mut lines = vec![meter_line(
+ "vCore",
+ &hero_text,
+ app.vcore.map(|v| v / vc_max),
+ vc_color,
+ w,
+ Some(app.cfg.vcore_limit / vc_max),
+ )];
if !app.cfg.graphs || w < GRAPHS_MIN_WIDTH {
- lines.push(meter_line("Pkg power", &fmt_opt_watts(app.package_watts),
- app.package_watts.map(|v| v / watt_max), Color::Blue, w, None));
- lines.push(meter_line("Pkg temp", &fmt_opt_temp(app.package_temp),
+ lines.push(meter_line(
+ "Pkg power",
+ &fmt_opt_watts(app.package_watts),
+ app.package_watts.map(|v| v / watt_max),
+ Color::Blue,
+ w,
+ None,
+ ));
+ lines.push(meter_line(
+ "Pkg temp",
+ &fmt_opt_temp(app.package_temp),
app.package_temp.map(|t| t / app.cfg.temp_max),
- app.package_temp.map_or(Color::DarkGray, |t| temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)), w, None));
+ app.package_temp.map_or(Color::DarkGray, |t| {
+ temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)
+ }),
+ w,
+ None,
+ ));
lines.push(Line::from(""));
}
render_lines(f, bottom_area, &lines);
@@ -839,11 +1040,26 @@ pub fn draw(f: &mut Frame, app: &App) {
Span::styled("Max freq: ", Style::default().fg(Color::Cyan)),
Span::raw(format!(
"{:.2} GHz",
- app.cores.iter().filter_map(|c| c.freq_khz).max().unwrap_or(0) as f64 / 1_000_000.0
+ app.cores
+ .iter()
+ .filter_map(|c| c.freq_khz)
+ .max()
+ .unwrap_or(0) as f64
+ / 1_000_000.0
)),
- Span::styled(" q: quit F2: settings", Style::default().fg(Color::DarkGray)),
+ Span::styled(
+ " q: quit F2: settings",
+ Style::default().fg(Color::DarkGray),
+ ),
]);
- f.render_widget(ratatui::text::Text::from(footer), Rect { y: footer_y, height: 1, ..area });
+ f.render_widget(
+ ratatui::text::Text::from(footer),
+ Rect {
+ y: footer_y,
+ height: 1,
+ ..area
+ },
+ );
}
}
@@ -851,12 +1067,19 @@ fn draw_settings(f: &mut Frame, area: Rect, app: &App) {
let h = (SETTINGS_ROWS.len() as u16 + 4).min(area.height); // title + rows + hint + margin
let w = 52u16.min(area.width);
let y = area.height.saturating_sub(h + 1) / 2;
- let rect = Rect { x: (area.width - w) / 2, y, width: w, height: h };
+ let rect = Rect {
+ x: (area.width - w) / 2,
+ y,
+ width: w,
+ height: h,
+ };
f.render_widget(ratatui::widgets::Clear, rect); // blank the graphs behind
let mut lines = vec![Line::from(Span::styled(
"Settings",
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
+ Style::default()
+ .fg(Color::Cyan)
+ .add_modifier(Modifier::BOLD),
))];
for (i, label) in SETTINGS_ROWS.iter().enumerate() {
let cursor = if i == app.settings_row { ">" } else { " " };
@@ -872,12 +1095,18 @@ fn draw_settings(f: &mut Frame, area: Rect, app: &App) {
Style::default()
};
lines.push(Line::from(vec![
- Span::styled(format!("{} {:<14}", cursor, label), Style::default().fg(Color::Cyan)),
+ Span::styled(
+ format!("{} {:<14}", cursor, label),
+ Style::default().fg(Color::Cyan),
+ ),
Span::styled(value, style),
]));
}
if let Some(err) = &app.settings_error {
- lines.push(Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red))));
+ lines.push(Line::from(Span::styled(
+ err.clone(),
+ Style::default().fg(Color::Red),
+ )));
} else {
lines.push(Line::from(""));
}
@@ -937,8 +1166,12 @@ pub fn run_tui() {
app.edit_buf.clear();
}
KeyCode::F(10) if app.settings_open => {
- save_conf(&app);
- app.settings_open = false;
+ if let Err(e) = save_conf(&app) {
+ // Failed save keeps the pane open; error inline.
+ app.settings_error = Some(format!("save failed: {e}"));
+ } else {
+ app.settings_open = false;
+ }
}
KeyCode::Char('q') | KeyCode::Esc
if !app.settings_open || key.code == KeyCode::Esc =>
@@ -967,10 +1200,11 @@ pub fn run_tui() {
/// Write the settings block back to the conf the app loaded (F10). No-op
/// when no file was loaded (defaults only).
-fn save_conf(app: &App) {
+fn save_conf(app: &App) -> Result<(), String> {
if let Some(path) = &app.cfg.loaded_path {
let text = std::fs::read_to_string(path).unwrap_or_default();
let updated = config::update_conf_text(&text, &app.cfg);
- let _ = std::fs::write(path, updated);
+ std::fs::write(path, updated).map_err(|e| format!("{}: {e}", path.display()))?;
}
-}
\ No newline at end of file
+ Ok(())
+}
final audit: rails removal, config hardening, CLI polish, Makefile
- config.rs: delete dead rail-map feature (Rail/cfg.rails/rail.* keys);
add /etc/aldermon/aldermon.conf search path; range-validate file
numerics (new rejects_out_of_range test)
- sensors.rs: sio_input reads {stem}_input directly (label-shadowing
hazard gone, ~30 sysfs reads/tick -> 1)
- plot.rs: rewrite stale decimation-era docs, drop unused window_ticks
param, dedupe marker comment, levels() under cfg(test)
- ui.rs: PlotSpec tuple -> named struct; resize_graphs() on poll_ms
edits too; save_conf failures surface in pane; panic-proof peak_x;
is_multiple_of (clippy zero)
- main.rs: --help + unknown-flag exit 2; --tui tty guard; fresh docs
- aldermon.conf: rail lines out (probe notes kept as comments)
- README: install section, config docs current; Makefile:
build/check/install(DESTDIR+PREFIX)/uninstall/clean
- whole crate rustfmt'd; 13 tests, clippy -D warnings clean,
pty-verified 30x100/40x120/24x60 + settings F10 round-trip
af309d6064a0680500a1462eabca0251e8711099
josie <josie@example.com> · 2026-09-19T23:52 ·
browse files at this commit
parents:
f24503a