//! 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, 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, /// 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, 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, /// 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, } /// 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 { let raw = raw.trim().to_ascii_lowercase(); if let Some(mins) = raw.strip_suffix('m') { return mins .trim() .parse::() .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::().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, unit: &str) -> Result { 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, freq_min_khz: u64, freq_max_khz: u64, temp: Option, } 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::().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 { 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 }, ); } } /// 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, 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 = 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) }; 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 // scrolls 1:1 — the graph_secs window caps how many ticks fit the // box; shorter windows leave the left side of the box blank. let inner_w_full = w.saturating_sub(2); // box interior at full width let ticks = (inner_w_full as usize).min(graph_ticks(&app.cfg)); // visible span (max) let ring_ticks = graph_ticks(&app.cfg); // scale/peak span 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); let ticks = (inner_w as usize).min(ticks); // 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, ticks, 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); } }