//! 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,
style::{Color, Modifier, Style},
text::{Line, Span},
};
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;
/// Safety limit, volts. User-tunable config value. Intel max Vcore
/// spec default for the 12600KF.
const VCORE_LIMIT: f64 = 1.403;
const VCORE_BAR_MAX: f64 = 1.50;
const TEMP_WARN: f64 = 80.0;
const TEMP_CRIT: f64 = 95.0;
/// Package power bar scale, watts (dGPU PPT ceiling as a sane display max).
const POWER_BAR_MAX: f64 = 200.0;
/// Temp bar scale, °C.
const TEMP_BAR_MAX: f64 = 100.0;
pub struct App {
vcore: Option<f64>,
vcore_peak: Option<f64>,
/// (timestamp, volts) per-tick maxima inside the 5-min peak window
peak_ring: Vec<(Instant, f64)>,
package_watts: Option<f64>,
package_temp: Option<f64>,
p_temps: Vec<(String, f64)>,
e_temps: Vec<(String, f64)>,
freq_max_khz: u64,
last_pkg_energy: Option<(u64, Instant)>,
}
impl App {
pub fn new() -> App {
App {
vcore: None,
vcore_peak: None,
peak_ring: Vec::new(),
package_watts: None,
package_temp: None,
p_temps: Vec::new(),
e_temps: Vec::new(),
freq_max_khz: 0,
last_pkg_energy: None,
}
}
fn poll(&mut self) {
self.vcore = sensors::sio_vcore();
if let Some(v) = self.vcore {
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),
);
}
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);
self.p_temps = temps
.iter()
.filter(|(l, _)| !l.contains("Package") && !is_e_core_label(l))
.cloned()
.collect();
self.e_temps = temps
.iter()
.filter(|(l, _)| is_e_core_label(l))
.cloned()
.collect();
}
self.freq_max_khz = sensors::cpu_frequencies()
.iter()
.map(|(_, khz)| *khz)
.max()
.unwrap_or(0);
// 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 == "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 {
self.package_watts =
Some((e - prev) as f64 / 1_000_000.0 / dt);
}
Some((e, Instant::now()))
}
None => Some((e, Instant::now())),
};
}
}
}
}
/// Verified coretemp mapping for the 12600KF: E-cores are Core 28..=31,
/// P-cores are Core 0/4/8/12/16/20 (plus "Package id 0").
fn is_e_core_label(label: &str) -> bool {
matches!(label, "Core 28" | "Core 29" | "Core 30" | "Core 31")
}
fn temp_color(t: f64) -> Color {
if t >= TEMP_CRIT {
Color::Red
} else if t >= TEMP_WARN {
Color::Yellow
} else {
Color::Green
}
}
fn vcore_color(v: f64) -> Color {
if v >= VCORE_LIMIT {
Color::Red
} else if v >= VCORE_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: Vec<char> = if value_text.len() >= inner {
value_text.chars().rev().take(inner).collect::<Vec<_>>()
.into_iter().rev().collect()
} else {
let pad = inner - value_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) {
let is_marker = Some(i) == marker_pos;
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(),
}
}
pub fn draw(f: &mut Frame, app: &App) {
let w = f.area().width;
let mut lines: Vec<Line> = Vec::new();
// ---- vCore hero ----
let vc_color = match app.vcore {
Some(v) => vcore_color(v),
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(),
};
lines.push(Line::from(Span::styled(
"vCore",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
lines.push(meter_line(
"",
&hero_text,
app.vcore.map(|v| v / VCORE_BAR_MAX),
vc_color,
w,
Some(VCORE_LIMIT / VCORE_BAR_MAX),
));
lines.push(Line::from(""));
// ---- package ----
lines.push(meter_line(
"Pkg power",
&fmt_opt_watts(app.package_watts),
app.package_watts.map(|v| v / POWER_BAR_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, temp_color),
w,
None,
));
lines.push(Line::from(""));
// ---- P cores ----
lines.push(Line::from(Span::styled(
"P-cores",
Style::default().fg(Color::Cyan),
)));
for (label, t) in &app.p_temps {
lines.push(meter_line(
label,
&format!("{:.0} °C", t),
Some(t / TEMP_BAR_MAX),
temp_color(*t),
w,
None,
));
}
lines.push(Line::from(""));
// ---- E cores ----
lines.push(Line::from(Span::styled(
"E-cores",
Style::default().fg(Color::Cyan),
)));
for (label, t) in &app.e_temps {
lines.push(meter_line(
label,
&format!("{:.0} °C", t),
Some(t / TEMP_BAR_MAX),
temp_color(*t),
w,
None,
));
}
lines.push(Line::from(""));
// ---- footer ----
lines.push(Line::from(vec![
Span::styled("Max freq: ", Style::default().fg(Color::Cyan)),
Span::raw(format!(
"{:.2} GHz",
app.freq_max_khz as f64 / 1_000_000.0
)),
Span::styled(" q: quit", Style::default().fg(Color::DarkGray)),
]));
f.render_widget(ratatui::text::Text::from(lines), f.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();
}