//! 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, vcore_peak: Option, /// (timestamp, volts) per-tick maxima inside the 5-min peak window peak_ring: Vec<(Instant, f64)>, package_watts: Option, package_temp: Option, 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, color: Color, width: u16, marker_at: Option, ) -> 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!("{: = if value_text.len() >= inner { value_text.chars().rev().take(inner).collect::>() .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) { // 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) -> String { match v { Some(w) => format!("{:.1} W", w), None => "unreadable (root)".to_string(), } } fn fmt_opt_temp(v: Option) -> 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 = 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(); }