//! 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, vcore_peak: Option, /// (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, /// Highest pkg watts ever seen this session (auto-scale anchor) watts_all_time_peak: f64, package_temp: Option, /// Per physical core: label (coretemp "Core N"), freq kHz, bar-max kHz /// (that core's cpuinfo_max_freq), temperature °C. cores: Vec, 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, freq_min_khz: u64, freq_max_khz: u64, temp: Option, } 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::().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 { 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, 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 text_len >= inner { value_text.chars().rev().take(inner).collect::>() .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) -> 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(), } } /// 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> { 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 = app.cores.iter().filter(|c| !c.e_core).cloned().collect(); let e: Vec = 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(); }