//! 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::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
};
use crate::config;
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,
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>,
}
/// 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 % 60 == 0 {
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;
}
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.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));
}
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() -> App {
let cfg = config::load();
let cap = ring_capacity(&cfg);
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),
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,
}
}
fn poll(&mut self) {
self.vcore = sensors::sio_vcore();
if let Some(v) = self.vcore {
self.vcore_graph.push(v);
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())),
};
}
}
}
}
/// 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)
}
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: (label, live text, peak text, ring, scale min/max,
/// 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, Color, Option<u16>, 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, used) = 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 used == 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 >= used || 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 = match app.vcore {
Some(v) => format!("{v:.3} V"),
None => "n/a".to_string(),
};
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);
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)
};
// Headroom so the trace fills the interior without kissing the
// borders: pad the window span by ~1 interior row top and bottom
// (plus a small per-metric floor so flat lines don't go degenerate).
let pad = |lo: f64, hi: f64, floor: f64| -> (f64, f64) {
let row_w = ((hi - lo) / (graph_rows.max(1) as f64)).max(floor);
((lo - row_w).max(0.0), hi + row_w)
};
let (vc_lo, vc_hi, ck_lo, ck_hi, pw_lo, pw_hi, tp_lo, tp_hi) =
if app.cfg.graph_scale_fixed {
(vc_lo, vc_hi, ck_lo, ck_hi, pw_lo, pw_hi, tp_lo, tp_hi)
} else {
let (a, b) = pad(vc_lo, vc_hi, 0.02);
let (c, d) = pad(ck_lo, ck_hi, 50_000.0);
let (e, f_) = pad(pw_lo, pw_hi, 1.0);
let (g, h) = pad(tp_lo, tp_hi, 1.0);
(a, b, c, d, e, f_, g, h)
};
// 4 boxed plots stacked top→bottom, sharing one time axis.
// Cascade: vCore → Peak clock → Pkg power → Pkg temp (user order).
let plots: [PlotSpec; 4] = [
("vCore", hero_text.clone(), hero_peak, &app.vcore_graph, vc_lo, vc_hi, vc_color,
Some(((1.0 - ((app.cfg.vcore_limit - vc_lo) / (vc_hi - 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, Color::Green, None, 1_000_000.0),
("Power", fmt_opt_watts(app.package_watts), watts_peak, &app.watts_graph, pw_lo, pw_hi, Color::Blue, None, 1.0),
("Temp", fmt_opt_temp(app.package_temp), temp_peak, &app.temp_graph, tp_lo, tp_hi,
app.package_temp.map_or(Color::DarkGray, |t| temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)), None, 1.0),
];
let mut y = bottom_y;
for (label, text, peak, ring, min, max, color, marker, axis_div) in plots {
let axis_max = plot::fmt_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}", min / axis_div)
} else {
plot::fmt_axis(min / axis_div)
};
// Axis gutter: max label top, min label bottom, right-aligned to
// the box. Sized to the labels (6 chars max) but at least
// KEY_RESERVE so the peak line stays clear of the key text.
let axis_w = (axis_max.chars().count().max(axis_min.chars().count()) as u16)
.clamp(3, 6)
.max(KEY_RESERVE);
let box_x = area.x + axis_w.max(KEY_RESERVE);
let box_w = w.saturating_sub(box_x - area.x);
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.
let key = Line::from(vec![
Span::styled(format!("{label} "), Style::default().fg(Color::Cyan)),
Span::styled(text.clone(), Style::default().add_modifier(Modifier::BOLD)),
]);
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);
let gutter_w = box_x - area.x;
let right_fit = |s: &str| -> String {
let n = s.chars().count();
if n > gutter_w as usize {
s.chars().skip(n - gutter_w as usize).collect()
} else {
format!("{}{}", " ".repeat(gutter_w as usize - n), s)
}
};
// max: top border row, right-aligned against the box
f.render_widget(
ratatui::text::Text::from(Line::from(Span::styled(right_fit(&axis_max), axis_st))),
Rect { x: area.x, y: box_rect.y, width: gutter_w, height: 1 },
);
// min: bottom border row, right-aligned against the box
f.render_widget(
ratatui::text::Text::from(Line::from(Span::styled(right_fit(&axis_min), axis_st))),
Rect { x: area.x, y: box_rect.y + box_h - 1, width: gutter_w, height: 1 },
);
let used = 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,
);
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));
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() {
let mut terminal = ratatui::init();
let mut app = App::new();
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 => {
save_conf(&app);
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) {
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);
}
}