//! htop-style TUI front-end. Consumes `sensors::` snapshots on a ~250 ms
//! tick; never touches sysfs itself. Style per design.md: terminal-default
//! background, one-line htop meters, ANSI color classes only.
use std::path::Path;
use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
Frame,
};
use crate::config;
use crate::log::{self, VidLogger};
use crate::plot::{self, Ring};
use crate::sensors;
const PEAK_WINDOW: Duration = Duration::from_secs(5 * 60);
/// vCore shown Yellow when within this margin of the limit, Red over it.
const VCORE_MARGIN: f64 = 0.05;
/// Auto-scale headroom above the session peak, as a fraction.
const SCALE_MARGIN: f64 = 0.05;
/// Temp graph scale/meter max defaults from cfg.temp_max now.
/// Graphs need at least this much width (meter fallback below it).
const GRAPHS_MIN_WIDTH: u16 = 70;
/// Minimum interior trace rows per boxed graph.
const GRAPH_MIN_ROWS: u16 = 2;
/// Width at/below which core sections stack to one column.
const DUAL_COLUMN_MIN_WIDTH: u16 = 100;
/// Reserved left margin: y-axis labels + trace never intrude into the key
/// column at this offset (peak lines run from here to the box's left edge).
const KEY_RESERVE: u16 = 8;
pub struct App {
cfg: config::Config,
vcore: Option<f64>,
vcore_peak: Option<f64>,
/// (timestamp, volts) per-tick maxima inside the 5-min peak window
peak_ring: Vec<(Instant, f64)>,
/// Highest vCore ever seen this session (auto-scale anchor)
vcore_all_time_peak: f64,
package_watts: Option<f64>,
/// Highest pkg watts ever seen this session (auto-scale anchor)
watts_all_time_peak: f64,
package_temp: Option<f64>,
/// Highest package temp ever seen this session.
temp_all_time_peak: f64,
/// Per physical core: label (coretemp "Core N"), freq kHz, bar-max kHz
/// (that core's cpuinfo_max_freq), temperature °C.
cores: Vec<CoreRow>,
last_pkg_energy: Option<(u64, Instant)>,
/// Graph history (newest last) for the package-zone plots.
vcore_graph: Ring,
/// Requested SVID setpoint history, overlaid on the vCore plot (blue).
/// Stays empty when MSR is unreadable (non-root) → overlay draws nothing.
vid_graph: Ring,
watts_graph: Ring,
temp_graph: Ring,
/// Peak clock (max freq across all cores per tick) graph history.
clock_graph: Ring,
/// Peak clock (max freq across all cores per tick) — current + peak.
clock_khz: Option<u64>,
/// Highest peak-clock ever seen this session (auto-scale anchor).
clock_all_time_peak: u64,
/// Settings pane open (F2); F10 commits+writes conf, Esc closes.
settings_open: bool,
/// Which settings row the cursor is on (index into SETTINGS_ROWS).
settings_row: usize,
/// Edit buffers (digit keys append, backspace pops; Enter applies).
edit_buf: String,
/// Last settings-input error (inline, cleared on next keypress).
settings_error: Option<String>,
/// MSR is readable (root + /dev/cpu/*/msr), probed once at startup. When
/// false the VID read is skipped each poll and the panel shows "VID n/a".
msr_readable: bool,
/// Requested SVID setpoint this poll, max across logical CPUs (V).
vid: Option<f64>,
/// Per-logical-cpu raw IA32_PERF_STATUS, for the CSV row.
vid_msr: Vec<Option<u64>>,
/// --log: CSV sink at ./aldermon-vid.log (None = logging off).
logger: Option<VidLogger>,
/// Logical CPU count, fixes the logger's per-cpu column count.
n_cpus: usize,
}
/// Settings-pane rows: (label, config field edited, unit).
const SETTINGS_ROWS: [&str; 11] = [
"Poll period",
"Graph window",
"Graph scale",
"vCore min",
"vCore max",
"Clock min",
"Clock max",
"Power min",
"Power max",
"Temp min",
"Temp max",
];
fn setting_value(app: &App, row: usize) -> String {
match row {
0 => format!("{} ms", app.cfg.poll_ms),
1 => {
// Minutes when the window is a whole minute or more.
let s = app.cfg.graph_secs;
if s >= 60 && s.is_multiple_of(60) {
format!("{} m", s / 60)
} else {
format!("{} s", s)
}
}
2 => {
if app.cfg.graph_scale_fixed {
"fixed".to_string()
} else {
"auto".to_string()
}
}
3 => fmt_volts(app.cfg.vcore_min),
4 => fmt_volts(app.cfg.vcore_bar_max),
5 => fmt_ghz(app.cfg.clock_min),
6 => fmt_ghz(app.cfg.clock_bar_max),
7 => format!("{:.1} W", app.cfg.power_min),
8 => format!("{:.1} W", app.cfg.power_bar_max),
9 => format!("{:.0} °C", app.cfg.temp_min),
_ => format!("{:.0} °C", app.cfg.temp_max),
}
}
fn fmt_volts(v: f64) -> String {
format!("{v:.3} V")
}
/// Clock scale is stored in kHz; show/edit it in GHz.
fn fmt_ghz(khz: f64) -> String {
format!("{:.2} GHz", khz / 1_000_000.0)
}
/// Window input accepts "90" (seconds) or "5m"/"2m30s" (minutes).
fn parse_window_secs(raw: &str) -> Result<u64, String> {
let raw = raw.trim().to_ascii_lowercase();
if let Some(mins) = raw.strip_suffix('m') {
return mins
.trim()
.parse::<u64>()
.map(|m| m * 60)
.map_err(|_| format!("'{raw}' is not a number"));
}
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"))?;
return Ok(m * 60 + s);
}
raw.parse::<u64>()
.map_err(|_| format!("'{raw}' is not a number"))
}
fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
match row {
0 => {
let v: u64 = raw
.parse()
.map_err(|_| format!("'{}' is not a number", raw))?;
if !(50..=10_000).contains(&v) {
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)?;
if !(5..=3600).contains(&v) {
return Err("window must be 5s..60m".into());
}
app.cfg.graph_secs = v;
app.resize_graphs();
}
2 => {
// Scale: toggle auto/fixed. Bare Enter toggles; also accept
// typed words for discoverability.
match raw.to_ascii_lowercase().as_str() {
"auto" => app.cfg.graph_scale_fixed = false,
"fixed" => app.cfg.graph_scale_fixed = true,
"" => app.cfg.graph_scale_fixed = !app.cfg.graph_scale_fixed,
_ => 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)?,
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)?,
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)?,
_ => parse_scale(raw, app.cfg.temp_min.max(10.0)..=150.0, "°C")
.map(|v| app.cfg.temp_max = v)?,
}
Ok(())
}
/// 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"))?;
if !range.contains(&v) {
return Err(format!("out of range ({unit})"));
}
Ok(v)
}
/// Ring size = graph window at the poll period, clamped to 512..=20000.
fn ring_capacity(cfg: &config::Config) -> usize {
((cfg.graph_secs * 1000 / cfg.poll_ms) as usize).clamp(512, 20_000)
}
/// Ticks (samples) shown in a plot for the configured window.
fn graph_ticks(cfg: &config::Config) -> usize {
(cfg.graph_secs * 1000 / cfg.poll_ms).max(1) as usize
}
/// Bar max = static default, or last peak + margin once the peak exceeds it.
/// The margin keeps the value text off the bar's right edge at the peak.
fn dynamic_max(peak: f64, static_max: f64, margin: f64) -> f64 {
(peak * (1.0 + margin)).max(static_max)
}
/// One physical core's row in the per-core section.
#[derive(Clone)]
struct CoreRow {
label: String,
e_core: bool,
freq_khz: Option<u64>,
freq_min_khz: u64,
freq_max_khz: u64,
temp: Option<f64>,
}
impl App {
pub fn new(logger: Option<VidLogger>, n_cpus: usize) -> App {
let cfg = config::load();
let cap = ring_capacity(&cfg);
// Probe MSR once: a successful read of cpu0's IA32_PERF_STATUS means
// we have root + /dev/cpu/0/msr. Avoids a stat syscall every poll.
let msr_readable = sensors::perf_status_msr(0).is_some();
App {
cfg,
vcore: None,
vcore_peak: None,
peak_ring: Vec::new(),
vcore_all_time_peak: 0.0,
package_watts: None,
watts_all_time_peak: 0.0,
package_temp: None,
temp_all_time_peak: 0.0,
cores: Vec::new(),
last_pkg_energy: None,
vcore_graph: Ring::new(cap),
vid_graph: Ring::new(cap),
watts_graph: Ring::new(cap),
temp_graph: Ring::new(cap),
clock_graph: Ring::new(cap),
clock_khz: None,
clock_all_time_peak: 0,
settings_open: false,
settings_row: 0,
edit_buf: String::new(),
settings_error: None,
msr_readable,
vid: None,
vid_msr: Vec::new(),
logger,
n_cpus,
}
}
/// 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.vid_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 self.msr_readable {
self.vid_msr = sensors::vid_msrs(self.n_cpus);
self.vid = sensors::vid_max(&self.vid_msr);
}
if let Some(v) = self.vcore {
self.vcore_graph.push(v);
// Push VID (or NaN on a transient miss) every vcore tick so the
// two rings stay column-aligned; NaN renders as a gap and is
// ignored by the scale folds.
self.vid_graph.push(self.vid.unwrap_or(f64::NAN));
let now = Instant::now();
self.peak_ring.push((now, v));
self.peak_ring
.retain(|(t, _)| now.duration_since(*t) <= PEAK_WINDOW);
self.vcore_peak = Some(
self.peak_ring
.iter()
.map(|(_, v)| *v)
.fold(f64::MIN, f64::max),
);
self.vcore_all_time_peak = self.vcore_all_time_peak.max(v);
}
if let Some(chip) = sensors::discover_chips()
.into_iter()
.find(|c| c.name == "coretemp")
{
let temps = sensors::temperatures(&chip);
self.package_temp = temps
.iter()
.find(|(l, _)| l.contains("Package"))
.map(|(_, v)| *v);
if let Some(t) = self.package_temp {
self.temp_graph.push(t);
self.temp_all_time_peak = self.temp_all_time_peak.max(t);
}
// Per physical core: coretemp label "Core N" N == topology
// core_id; cpufreq is per logical cpu, so a core's speed is the
// max across its HT siblings. Verified mapping for the 12600KF:
// P core_ids 0/4/8/12/16/20 → cpus (0,1)..(10,11), E 28..31 →
// cpu12..15 (no HT).
let freqs = sensors::cpu_frequencies();
self.cores = temps
.iter()
.filter(|(l, _)| l.starts_with("Core "))
.map(|(label, t)| {
let core_id: u64 = label
.strip_prefix("Core ")
.and_then(|d| d.parse().ok())
.unwrap_or(u64::MAX);
let cpus = core_to_cpus(core_id);
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))
.max();
let freq_max_khz = cpus
.iter()
.filter_map(|c| sensors::cpu_max_freq(*c))
.max()
.unwrap_or(0);
let freq_min_khz = cpus
.iter()
.filter_map(|c| sensors::cpu_min_freq(*c))
.max()
.unwrap_or(0);
CoreRow {
label: label.clone(),
e_core,
freq_khz,
freq_min_khz,
freq_max_khz,
temp: Some(*t),
}
})
.collect();
self.cores
.sort_by_key(|c| (c.e_core, c.label[5..].parse::<u64>().unwrap_or(u64::MAX)));
// Peak clock: max freq across all cores this tick (kHz).
if let Some(peak_khz) = self.cores.iter().filter_map(|c| c.freq_khz).max() {
self.clock_graph.push(peak_khz as f64);
self.clock_khz = Some(peak_khz);
self.clock_all_time_peak = self.clock_all_time_peak.max(peak_khz);
}
}
// Watts = energy delta over the tick (poll cadence IS the sample
// interval; no extra sleep like the dump's fixed 500 ms window).
if let Some(d) = sensors::rapl_domains()
.iter()
.find(|d| d.name.starts_with("package"))
{
if let Some(e) = sensors::rapl_energy_uj(&d.id) {
self.last_pkg_energy = match self.last_pkg_energy {
Some((prev, t0)) => {
let dt = t0.elapsed().as_secs_f64();
if dt > 0.0 && e >= prev {
let w = (e - prev) as f64 / 1_000_000.0 / dt;
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);
}
}
Some((e, Instant::now()))
}
None => Some((e, Instant::now())),
};
}
}
// Append this poll's cross-check row (--log), after all sensors for
// the tick have been read so the CSV and the display agree.
if let Some(logger) = self.logger.as_mut() {
let freqs: Vec<Option<u64>> = {
let mut out = vec![None; self.n_cpus];
for (cpu, khz) in sensors::cpu_frequencies() {
if let Some(slot) = out.get_mut(cpu) {
*slot = Some(khz);
}
}
out
};
let sample = log::Sample {
sio_vcore: self.vcore,
vid_max: self.vid,
pkg_watts: self.package_watts,
vid_msr: self.vid_msr.clone(),
freq_khz: freqs,
};
if let Err(e) = logger.log(&sample) {
self.settings_error = Some(format!("log write failed: {e}"));
}
}
}
}
/// Verified topology mapping for the 12600KF: physical core N with HT has
/// logical cpus (2k, 2k+1); P core_ids 0/4/8/12/16/20 map to pairs starting
/// at core_id/2·2. E-cores 28..31 map 1:1 to cpu12..15.
fn core_to_cpus(core_id: u64) -> Vec<usize> {
match core_id {
0 => vec![0, 1],
4 => vec![2, 3],
8 => vec![4, 5],
12 => vec![6, 7],
16 => vec![8, 9],
20 => vec![10, 11],
28..=31 => vec![core_id as usize - 16],
_ => Vec::new(),
}
}
fn temp_color(t: f64, warn: f64, crit: f64) -> Color {
if t >= crit {
Color::Red
} else if t >= warn {
Color::Yellow
} else {
Color::Green
}
}
fn vcore_color(v: f64, limit: f64) -> Color {
if v >= limit {
Color::Red
} else if v >= limit - VCORE_MARGIN {
Color::Yellow
} else {
Color::Green
}
}
/// One htop-style meter line: `Label [||||| 1.152 V ]`.
/// `ratio` is fill fraction (None → empty, sensor missing). `marker_at`
/// (fraction) draws a bold `!` at that bar position — the vCore limit.
fn meter_line(
label: &str,
value_text: &str,
ratio: Option<f64>,
color: Color,
width: u16,
marker_at: Option<f64>,
) -> Line<'static> {
let cap_w = 12usize;
let inner = width.saturating_sub(cap_w as u16 + 2) as usize; // room for [ ]
let mut spans = vec![Span::styled(
format!("{:<w$}", label, w = cap_w),
Style::default().fg(Color::Cyan),
)];
let ratio = ratio.unwrap_or(0.0).clamp(0.0, 1.0);
let fill = (ratio * inner as f64).round() as usize;
let marker_pos = marker_at
.map(|m| ((m.clamp(0.0, 1.0)) * inner as f64).round() as usize)
.map(|p| p.min(inner.saturating_sub(1)));
// 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()
} else {
let pad = inner - text_len;
std::iter::repeat_n(' ', pad)
.chain(value_text.chars())
.collect()
};
spans.push(Span::raw("["));
for (i, &tc) in text.iter().enumerate().take(inner) {
// marker needs a blank cell underneath; never eat the value text
let is_marker = Some(i) == marker_pos && tc == ' ';
let bg_ch = if i < fill { '|' } else { ' ' };
let (ch, style) = if is_marker {
(
'!',
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
} else if tc != ' ' {
(tc, Style::default().add_modifier(Modifier::BOLD))
} else if i < fill {
(bg_ch, Style::default().fg(color))
} else {
(' ', Style::default())
};
spans.push(Span::styled(ch.to_string(), style));
}
spans.push(Span::raw("]"));
Line::from(spans)
}
/// vCore panel text: delivered (SIO in0), requested SVID setpoint, and the
/// delta (delivered − requested; negative = VRM droop, positive = LLC
/// overshoot). VID needs root + /dev/cpu/*/msr; `msr_readable` distinguishes
/// "not root" from a transient read miss.
fn hero_text(vcore: Option<f64>, vid: Option<f64>, msr_readable: bool) -> String {
let base = match vcore {
Some(v) => format!("{v:.3} V"),
None => "n/a".to_string(),
};
match (vid, vcore) {
(Some(ask), Some(got)) => format!("{base} VID {ask:.3} Δ{:+.3}", got - ask),
(Some(ask), None) => format!("{base} VID {ask:.3}"),
(None, _) if msr_readable => format!("{base} VID n/a"),
(None, _) => format!("{base} VID n/a (needs root)"),
}
}
/// Key-row spans for a plot caption: cyan label + bold live value. When the
/// plot has an overlay AND the value carries a numeric `VID <x>` token, that
/// token is tinted the overlay's trace color (blue) so the second line needs
/// no separate legend; a non-numeric `VID n/a` stays default (nothing drawn).
fn key_spans(label: &str, live: &str, overlay_color: Option<Color>) -> Vec<Span<'static>> {
let bold = Style::default().add_modifier(Modifier::BOLD);
let mut spans = vec![Span::styled(
format!("{label} "),
Style::default().fg(Color::Cyan),
)];
if let Some(color) = overlay_color {
if let Some(pos) = live.find("VID ") {
if live[pos + 4..].starts_with(|c: char| c.is_ascii_digit()) {
let end = live[pos..]
.find(" ")
.map(|e| pos + e)
.unwrap_or(live.len());
spans.push(Span::styled(live[..pos].to_string(), bold));
spans.push(Span::styled(
live[pos..end].to_string(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(live[end..].to_string(), bold));
return spans;
}
}
}
spans.push(Span::styled(live.to_string(), bold));
spans
}
fn fmt_opt_watts(v: Option<f64>) -> String {
match v {
Some(w) => format!("{:.1} W", w),
None => "unreadable (root)".to_string(),
}
}
fn fmt_opt_temp(v: Option<f64>) -> String {
match v {
Some(t) => format!("{:.0} °C", t),
None => "n/a".to_string(),
}
}
/// Build a core-column's lines: section caption + one meter per core with
/// a blank separator after. Width adapts to the column rect.
fn core_section_lines(title: &str, cores: &[CoreRow], app: &App, width: u16) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(Span::styled(
title.to_string(),
Style::default().fg(Color::Cyan),
))];
for core in cores {
let text = match (core.freq_khz, core.temp) {
(Some(f), Some(t)) => format!("{:.2}GHz {:.0}°C", f as f64 / 1_000_000.0, t),
(Some(f), None) => format!("{:.2}GHz", f as f64 / 1_000_000.0),
(None, Some(t)) => format!("{:.0}°C", t),
(None, None) => "n/a".to_string(),
};
// Bar spans the cpu's own [min_freq, max_freq] window so the
// 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)
.clamp(0.0, 1.0),
),
_ => None,
};
let color = match core.temp {
Some(t) => temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit),
None => Color::DarkGray,
};
lines.push(meter_line(&core.label, &text, ratio, color, width, None));
}
lines.push(Line::from(""));
lines
}
/// Render one meter line inside a column rect (render_widget per line row).
fn render_lines(f: &mut Frame, area: Rect, lines: &[Line<'static>]) {
for (i, line) in lines.iter().enumerate() {
if (i as u16) >= area.height {
break;
}
f.render_widget(
ratatui::text::Text::from(line.clone()),
Rect {
x: area.x,
y: area.y + i as u16,
width: area.width,
height: 1,
},
);
}
}
/// 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,
/// Second series drawn over the same bounds (color: VID blue on vCore).
overlay: Option<(&'a Ring, Color)>,
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();
let w = area.width;
let dual = match app.cfg.layout {
config::Layout::Single => false,
config::Layout::Dual => true,
config::Layout::Auto => w >= DUAL_COLUMN_MIN_WIDTH,
};
// ---- vertical budget: cores keep what they need, plots flex ----
// Full readout needs caption + core rows + blank (8 dual / 12 single).
// Too short for full cores AND min-height plots → cores collapse to
// one meter per class; still too short → plots drop to 1-row traces,
// then to the meter fallback when even that won't fit.
let footer_h = 1u16;
let graphs_on = app.cfg.graphs && w >= GRAPHS_MIN_WIDTH;
let cores_full: u16 = if dual { 8 } else { 12 };
let compact_h: u16 = if dual { 2 } else { 3 }; // meter row(s) + blank
let plots_min: u16 = 4 * (GRAPH_MIN_ROWS + 3) + 1; // +separator row
let (cores_h, graph_rows) = if !graphs_on {
(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),
)
} else if area.height >= compact_h + plots_min + footer_h {
(compact_h, GRAPH_MIN_ROWS)
} else {
(
compact_h,
(area.height.saturating_sub(compact_h + footer_h + 1) / 4).saturating_sub(3),
)
};
let box_h = graph_rows + 2;
let plot_h = graph_rows + 3; // box + key row above
let bottom_h: u16 = if graph_rows >= 1 {
4 * plot_h
} else {
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 bottom_y = area.y + cores_h;
let footer_y = area.y + area.height.saturating_sub(footer_h);
// ---- cores zone: P column | E column, or single column ----
// Shorter than the full readout → one meter per class (max across
// 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, 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).
let mut compact = |title: &str, cores: &[CoreRow], rect: Rect| {
if cores.is_empty() || rect.width == 0 {
return;
}
let freq = cores.iter().filter_map(|c| c.freq_khz).max();
let temp = cores.iter().filter_map(|c| c.temp).reduce(f64::max);
let text = match (freq, temp) {
(Some(fq), Some(t)) => format!("{:.2}GHz {:.0}°C", fq as f64 / 1_000_000.0, t),
(Some(fq), None) => format!("{:.2}GHz", fq as f64 / 1_000_000.0),
(None, Some(t)) => format!("{:.0}°C", t),
(None, None) => "n/a".to_string(),
};
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))
}
_ => 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)],
);
};
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
},
),
];
for (i, (title, cores, rect)) in sections.into_iter().enumerate() {
if i >= n_cols || rect.width == 0 {
continue;
}
let lines = core_section_lines(title, &cores, app, rect.width);
render_lines(f, rect, &lines);
}
}
// ---- package zone (bottom) ----
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 {
Some(v) => vcore_color(v, app.cfg.vcore_limit),
None => Color::DarkGray,
};
let hero_text = hero_text(app.vcore, app.vid, app.msr_readable);
let hero_peak = match app.vcore_peak {
Some(p) => format!("peak {p:.3}"),
None => String::new(),
};
let clock_text = match app.clock_khz {
Some(k) => format!("{:.2} GHz", k as f64 / 1_000_000.0),
None => "n/a".to_string(),
};
let clock_peak = if app.clock_all_time_peak > 0 {
format!("peak {:.2}", app.clock_all_time_peak as f64 / 1_000_000.0)
} else {
String::new()
};
let watts_peak = if app.watts_graph.peak() > f64::MIN {
format!("peak {:.1}", app.watts_graph.peak())
} else {
String::new()
};
let temp_peak = if app.temp_all_time_peak > 0.0 {
format!("peak {:.0}", app.temp_all_time_peak)
} else {
String::new()
};
if graph_rows >= 1 && !app.settings_open {
// Graphs sit in light-grey boxes; they skip rendering while the
// settings pane is open (the pane owns the screen). The plot key
// (label + live value) is left-aligned in the gutter. The trace is
// braille sub-pixel, 1:1 (one sample per sub-col, 2 per cell),
// right-pinned — NO decimation, NO hysteresis. Visible window =
// sub_w × poll_ms (sub_w = 2 × inner_w); graph_secs sizes the
// ring for peaks/scale only, NOT the visible span. Middle-of-trace
// dots never change as the window scrolls (btop rule); only the
// right edge wiggles as new samples arrive. The 4-level sub-row
// quantization is the noise floor — small jitters map to the same
// sub-row and don't move the dot.
let ring_ticks = graph_ticks(&app.cfg); // peak/scale span (ring history)
let ring_max = |ring: &Ring, floor: f64| -> f64 {
(ring.max_window(ring_ticks) * (1.0 + SCALE_MARGIN)).max(floor)
};
// Fixed scale (settings pane / graph_scale_fixed): the min/max keys
// below, never rescaled mid-benchmark — peaks clip at the top instead
// of the whole trace compacting. Auto: window min/max + margin, so
// the trace fills the interior vertically; bounds never collapse,
// and one-row headroom at each edge keeps the line ~2 cells clear
// of the box borders.
let ring_min = |ring: &Ring| ring.min_window(ring_ticks);
// 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 {
// vCore + VID share one scale, so fold both rings' window bounds.
let vc = (
ring_min(&app.vcore_graph).min(ring_min(&app.vid_graph)),
ring_max(&app.vcore_graph, 0.0).max(ring_max(&app.vid_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
// to a COPY of the raw bounds; the raw bounds stay for axis labels.
let pad = |lo: f64, hi: f64, floor: f64| -> (f64, f64) {
let row_w = ((hi - lo) / (graph_rows.max(1) as f64) * 0.25).max(floor);
((lo - row_w).max(0.0), hi + row_w)
};
let (p_vc_lo, p_vc_hi) = pad(vc_lo, vc_hi, 0.02);
let (p_ck_lo, p_ck_hi) = pad(ck_lo, ck_hi, 50_000.0);
let (p_pw_lo, p_pw_hi) = pad(pw_lo, pw_hi, 1.0);
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] = [
PlotSpec {
label: "vCore",
live: hero_text.clone(),
peak: hero_peak,
ring: &app.vcore_graph,
overlay: Some((&app.vid_graph, Color::Blue)),
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,
overlay: None,
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,
overlay: None,
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,
overlay: None,
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 PlotSpec {
label,
live,
peak,
ring,
overlay,
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".
format!("{:.1}", axis_min / axis_div)
} else {
plot::fmt_axis(axis_min / axis_div)
};
// Box spans the full width — no left gutter. Axis labels
// overlay the top/bottom BORDER rows, just inside the ┌/└
// corners (the braille trace lives in the interior, so the
// border rows are free for labels). KEY_RESERVE still reserves
// the left of the key row so the peak line stays clear of the
// label text.
let box_x = area.x;
let box_w = w;
let inner_w = box_w.saturating_sub(2);
// Key row ABOVE the box (label + live value left-aligned at
// col 0; session peak right-aligned against the trace's right
// edge, never left of KEY_RESERVE). Single spans per style —
// ratatui's line diff drops separator spans in narrow rects.
// The overlay's key chunk (VID) is tinted like its trace, so
// the blue line is legible without a legend.
let key = Line::from(key_spans(label, &live, overlay.map(|(_, c)| c)));
f.render_widget(
ratatui::text::Text::from(key),
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,
};
f.render_widget(
ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
box_rect,
);
let axis_st = Style::default().fg(Color::DarkGray);
// Overlay max/min labels on the border rows, just inside the
// left corner (after ┌ / └). First-blank-wins: border glyphs
// are already rendered, so we overwrite the first few cells
// inside the border with the label text.
let max_w = axis_max.chars().count() as u16;
let min_w = axis_min.chars().count() as u16;
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,
},
);
}
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,
},
);
}
let trace_cols = plot::render(
f,
Rect {
x: box_rect.x + 1,
y: box_rect.y + 1,
width: inner_w,
height: graph_rows,
},
ring,
trace_min,
trace_max,
color,
marker,
overlay,
);
if !peak.is_empty() {
let peak_w = peak.chars().count() as u16;
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,
},
);
}
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),
)];
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),
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,
));
lines.push(Line::from(""));
}
render_lines(f, bottom_area, &lines);
}
// ---- footer (or settings pane overlay) ----
if app.settings_open {
draw_settings(f, area, app);
} else {
let footer = Line::from(vec![
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
)),
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
},
);
}
}
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,
};
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),
))];
for (i, label) in SETTINGS_ROWS.iter().enumerate() {
let cursor = if i == app.settings_row { ">" } else { " " };
let editing = app.settings_row == i && !app.edit_buf.is_empty();
let value = if editing {
format!("{}_", app.edit_buf)
} else {
setting_value(app, i)
};
let style = if i == app.settings_row {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(vec![
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),
)));
} else {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
"↑↓ select Enter edit/apply F10 save Esc close",
Style::default().fg(Color::DarkGray),
)));
f.render_widget(ratatui::text::Text::from(lines), rect);
}
/// Settings-pane key handling. Returns Err(msg) shown inline on bad input.
fn settings_key(app: &mut App, key: KeyCode) -> Result<(), String> {
match key {
KeyCode::Up => {
app.settings_row = app.settings_row.saturating_sub(1);
app.edit_buf.clear();
}
KeyCode::Down => {
app.settings_row = (app.settings_row + 1).min(SETTINGS_ROWS.len() - 1);
app.edit_buf.clear();
}
KeyCode::Enter => {
let raw = std::mem::take(&mut app.edit_buf);
setting_absorb(app, app.settings_row, &raw)?;
}
KeyCode::Backspace => {
app.edit_buf.pop();
}
KeyCode::Esc => {
app.settings_open = false;
app.edit_buf.clear();
}
KeyCode::Char(c) if c.is_ascii_digit() || matches!(c, '.' | 'm' | 's') => {
app.edit_buf.push(c)
}
_ => {}
}
Ok(())
}
pub fn run_tui(log_enabled: bool) {
let n_cpus = sensors::cpu_count();
let logger = if log_enabled {
match VidLogger::open(Path::new(crate::LOG_PATH), n_cpus) {
Ok(l) => Some(l),
Err(e) => {
eprintln!("aldermon: cannot open {}: {e}", crate::LOG_PATH);
std::process::exit(1);
}
}
} else {
None
};
let mut terminal = ratatui::init();
let mut app = App::new(logger, n_cpus);
loop {
app.poll();
terminal.draw(|f| draw(f, &app)).expect("draw failed");
let poll_ms = app.cfg.poll_ms;
if event::poll(Duration::from_millis(poll_ms)).unwrap_or(false) {
if let Ok(Event::Key(key)) = event::read() {
if key.kind != crossterm::event::KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::F(2) => {
app.settings_open = true;
app.settings_error = None;
app.edit_buf.clear();
}
KeyCode::F(10) if app.settings_open => {
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 =>
{
// Esc inside the pane closes the pane; otherwise quit.
if app.settings_open {
app.settings_open = false;
app.edit_buf.clear();
} else {
break;
}
}
code if app.settings_open => {
app.settings_error = None;
if let Err(e) = settings_key(&mut app, code) {
app.settings_error = Some(e);
}
}
_ => {}
}
}
}
}
ratatui::restore();
}
/// 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) -> 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);
std::fs::write(path, updated).map_err(|e| format!("{}: {e}", path.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_spans_tints_only_the_numeric_vid_token() {
let spans = key_spans("vCore", "1.016 V VID 1.100 Δ-0.084", Some(Color::Blue));
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "vCore 1.016 V VID 1.100 Δ-0.084");
let blue: Vec<&str> = spans
.iter()
.filter(|s| s.style.fg == Some(Color::Blue))
.map(|s| s.content.as_ref())
.collect();
assert_eq!(blue, ["VID 1.100"]);
}
#[test]
fn key_spans_leaves_unreadable_vid_default() {
// No numeric VID → no blue token (nothing is drawn for it).
let spans = key_spans("vCore", "1.016 V VID n/a", Some(Color::Blue));
assert!(spans.iter().all(|s| s.style.fg != Some(Color::Blue)));
// No overlay → plain caption, no tinting.
let spans = key_spans("Power", "95.5 W", None);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "Power 95.5 W");
}
#[test]
fn hero_shows_vid_and_signed_delta() {
// delivered 0.668, requested 0.821 → −153 mV (droop).
let t = hero_text(Some(0.668), Some(0.821), true);
assert_eq!(t, "0.668 V VID 0.821 Δ-0.153");
// delivered above requested → + sign.
assert_eq!(
hero_text(Some(1.30), Some(1.20), true),
"1.300 V VID 1.200 Δ+0.100"
);
}
#[test]
fn hero_distinguishes_not_root_from_transient_miss() {
assert_eq!(
hero_text(Some(1.0), None, false),
"1.000 V VID n/a (needs root)"
);
assert_eq!(hero_text(Some(1.0), None, true), "1.000 V VID n/a");
}
#[test]
fn hero_handles_missing_vcore() {
assert_eq!(hero_text(None, Some(1.2), true), "n/a VID 1.200");
assert_eq!(hero_text(None, None, false), "n/a VID n/a (needs root)");
}
}