//! 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 TICK_MS: u64 = 250;
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 bar scale, °C.
const TEMP_BAR_MAX: f64 = 100.0;
/// Graph rows in the package zone (below each caption line).
const GRAPH_ROWS: u16 = 5;
/// Below this width the package zone falls back to meter rows (no graphs).
const GRAPHS_MIN_WIDTH: u16 = 70;
/// Width at/below which core sections stack to one column.
const DUAL_COLUMN_MIN_WIDTH: u16 = 100;
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>,
/// 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,
}
/// 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();
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,
cores: Vec::new(),
last_pkg_energy: None,
vcore_graph: Ring::new(),
watts_graph: Ring::new(),
temp_graph: Ring::new(),
}
}
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);
}
// 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)));
}
// 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 },
);
}
}
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 zone flexes, package zone fixed, footer 1
let footer_h = 1u16;
let bottom_h: u16 = if app.cfg.graphs && w >= GRAPHS_MIN_WIDTH {
GRAPH_ROWS + 1 // caption line above each 5-row graph
} else {
2 // pkg power + pkg temp meter rows
};
let bottom_h = bottom_h + 1; // blank separator above footer
let cores_h = area.height.saturating_sub(bottom_h + footer_h);
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 ----
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 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, app.vcore_peak) {
(Some(v), Some(p)) => format!("{:.3} V peak {:.3}", v, p),
(Some(v), None) => format!("{:.3} V", v),
(None, _) => "n/a".to_string(),
};
if app.cfg.graphs && w >= GRAPHS_MIN_WIDTH {
let thirds = w / 3;
let mut x = area.x;
// Graphs scale to the RING's own window (recent history), not the
// session all-time peak — a flat idle line pinned to the bottom of
// a 1.5 V scale reads as broken. Hero bar keeps all-time scale.
let ring_max = |ring: &Ring, floor: f64| -> f64 {
(ring.max() * (1.0 + SCALE_MARGIN)).max(floor)
};
let vc_graph_max = ring_max(&app.vcore_graph, 1.0);
let watt_graph_max = ring_max(&app.watts_graph, 10.0);
for (i, (label, text, ring, max, color, marker)) in [
("vCore", hero_text.clone(), &app.vcore_graph, vc_graph_max, vc_color,
Some(((1.0 - (app.cfg.vcore_limit / vc_graph_max).clamp(0.0, 1.0)) * (GRAPH_ROWS - 1) as f64).round() as u16)),
("Pkg power", fmt_opt_watts(app.package_watts), &app.watts_graph, watt_graph_max, Color::Blue, None),
("Pkg temp", fmt_opt_temp(app.package_temp), &app.temp_graph,
ring_max(&app.temp_graph, 0.0).max(50.0),
app.package_temp.map_or(Color::DarkGray, |t| temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)), None),
].into_iter().enumerate() {
let span_w = if i == 2 { w - thirds * 2 } else { thirds };
let rect = Rect { x, y: bottom_y, width: span_w.max(1), height: bottom_h };
let caption = Line::from(vec![
Span::styled(label, Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled(text, Style::default().add_modifier(Modifier::BOLD)),
]);
f.render_widget(ratatui::text::Text::from(caption), Rect { height: 1, ..rect });
plot::render(f, Rect { y: rect.y + 1, height: GRAPH_ROWS, ..rect }, ring, max, color, marker);
x += thirds;
}
} else {
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 / TEMP_BAR_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 ----
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", Style::default().fg(Color::DarkGray)),
]);
f.render_widget(ratatui::text::Text::from(footer), Rect { y: footer_y, height: 1, ..area });
}
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");
if event::poll(Duration::from_millis(TICK_MS))
.unwrap_or(false)
{
if let Ok(Event::Key(key)) = event::read() {
if key.kind == crossterm::event::KeyEventKind::Press
&& matches!(key.code, KeyCode::Char('q') | KeyCode::Esc)
{
break;
}
}
}
}
ratatui::restore();
}