diff --git a/src/config.rs b/src/config.rs
index 6362eb5..5d136df 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -19,6 +19,17 @@ pub struct Config {
pub temp_crit: f64,
pub vcore_bar_max: f64,
pub power_bar_max: f64,
+ /// Core-column arrangement: auto (2 cols if wide), single, or dual.
+ pub layout: Layout,
+ /// Show nvtop-style line graphs in the package zone.
+ pub graphs: bool,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Layout {
+ Auto,
+ Single,
+ Dual,
}
impl Default for Config {
@@ -30,31 +41,35 @@ impl Default for Config {
temp_crit: 95.0,
vcore_bar_max: 1.50,
power_bar_max: 200.0,
+ layout: Layout::Auto,
+ graphs: true,
}
}
}
-/// Default rail map: ASRock Z690M ITX/ax (nct6798). in0 CONFIRMED Vcore;
-/// differential stress-ng probing 2026-08-29: in1/in12/in14 are dead-flat
-/// fixed rails (NOT Vcore, NOT DDR5 VDD); in4/in9 respond to CPU compute;
-/// in5/6/10/11/13 float (unwired). "?" = evidence-based guess.
+/// Default rail map: ASRock Z690M ITX/ax (nct6798). in0 CONFIRMED Vcore
+/// (shown as the hero meter, not a rail row). v2 view: only CPU-relevant
+/// rails visible by default (in4 VCCIO?, in9 1.05V PROC); the others stay
+/// mapped but hidden — re-enable via `rail.inN.hide = false` in the conf.
+/// Differential stress-ng probing 2026-08-29 + BIOS cross-check; "?" =
+/// evidence-based guess.
pub fn default_rails() -> Vec<Rail> {
vec![
rail("in0", "CPU Vcore", false),
- rail("in1", "Rail 1.01", false),
- rail("in2", "+5V", false),
- rail("in3", "+3.3V", false),
+ rail("in1", "Fixed 1.01", true),
+ rail("in2", "Fixed 3.42", true),
+ rail("in3", "+3.3V", true),
rail("in4", "VCCIO?", false),
rail("in5", "", true),
rail("in6", "", true),
- rail("in7", "+5V", false),
- rail("in8", "3.3V aux", false),
- rail("in9", "CPU rail 2?", false),
+ rail("in7", "Fixed 3.42", true),
+ rail("in8", "3.3V aux", true),
+ rail("in9", "1.05V PROC", false),
rail("in10", "", true),
rail("in11", "", true),
- rail("in12", "Fixed 1.14", false),
+ rail("in12", "Fixed 1.14", true),
rail("in13", "", true),
- rail("in14", "Fixed 1.5V", false),
+ rail("in14", "Fixed 1.5V", true),
]
}
@@ -86,6 +101,14 @@ fn apply_line(cfg: &mut Config, line: &str) {
"temp_crit" => cfg.temp_crit = val.parse().unwrap_or(cfg.temp_crit),
"vcore_bar_max" => cfg.vcore_bar_max = val.parse().unwrap_or(cfg.vcore_bar_max),
"power_bar_max" => cfg.power_bar_max = val.parse().unwrap_or(cfg.power_bar_max),
+ "graphs" => cfg.graphs = val == "true",
+ "layout" => {
+ cfg.layout = match val {
+ "single" => Layout::Single,
+ "dual" => Layout::Dual,
+ _ => Layout::Auto,
+ }
+ }
"rail.hide" => return, // needs input suffix; handled below
_ => {}
}
@@ -165,4 +188,24 @@ mod tests {
apply_line(&mut cfg, "vcore_limit = notanumber");
assert_eq!(cfg.vcore_limit, 1.403);
}
+
+ #[test]
+ fn parses_layout_and_graphs() {
+ let mut cfg = Config::default();
+ apply_line(&mut cfg, "layout = dual");
+ apply_line(&mut cfg, "graphs = false");
+ assert_eq!(cfg.layout, Layout::Dual);
+ assert!(!cfg.graphs);
+ apply_line(&mut cfg, "layout = nonsense");
+ assert_eq!(cfg.layout, Layout::Auto);
+ }
+
+ #[test]
+ fn non_cpu_rails_hidden_by_default() {
+ let cfg = Config::default();
+ let hidden: Vec<&str> = cfg.rails.iter().filter(|r| r.hide).map(|r| r.input.as_str()).collect();
+ assert_eq!(hidden, ["in1", "in2", "in3", "in5", "in6", "in7", "in8", "in10", "in11", "in12", "in13", "in14"]);
+ let shown: Vec<&str> = cfg.rails.iter().filter(|r| !r.hide && r.input != "in0").map(|r| r.input.as_str()).collect();
+ assert_eq!(shown, ["in4", "in9"]);
+ }
}
diff --git a/src/main.rs b/src/main.rs
index f9eedb3..e0d9bac 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,7 @@
//! TUI consumes the same `sensors` layer once the vCore source spike is done.
mod config;
+mod plot;
mod sensors;
mod ui;
diff --git a/src/plot.rs b/src/plot.rs
new file mode 100644
index 0000000..e6072ed
--- /dev/null
+++ b/src/plot.rs
@@ -0,0 +1,110 @@
+//! nvtop-style line plot, re-implemented for ratatui (pattern studied in
+//! references/nvtop/src/plot.c — GPL, code never copied). A `Ring` is a
+//! fixed-capacity sample buffer; the rightmost drawn column is the newest.
+//! Cell glyphs: level `─`, rises/falls get corner glyphs, multi-row jumps
+//! get `│` between them.
+
+use ratatui::{
+ Frame,
+ layout::Rect,
+ style::{Color, Style},
+ text::{Line, Span},
+};
+
+/// Ring of samples, oldest first. Capacity 512 ticks ≈ 2 min at the 250 ms
+/// cadence; plots clip to the visible width anyway.
+pub struct Ring {
+ data: Vec<f64>,
+ start: usize,
+ len: usize,
+}
+
+const RING_CAP: usize = 512;
+
+impl Ring {
+ pub fn new() -> Ring {
+ Ring {
+ data: vec![0.0; RING_CAP],
+ start: 0,
+ len: 0,
+ }
+ }
+
+ pub fn push(&mut self, v: f64) {
+ let end = (self.start + self.len) % RING_CAP;
+ self.data[end] = v;
+ if self.len < RING_CAP {
+ self.len += 1;
+ } else {
+ self.start = (self.start + 1) % RING_CAP;
+ }
+ }
+
+ pub fn samples(&self) -> impl Iterator<Item = f64> + '_ {
+ (0..self.len).map(move |i| self.data[(self.start + i) % RING_CAP])
+ }
+}
+
+fn levels(samples: &[f64], max: f64, rows: usize) -> Vec<usize> {
+ // Row 0 = top of the plot = max value.
+ samples
+ .iter()
+ .map(|&v| (((1.0 - v / max).clamp(0.0, 1.0)) * (rows - 1) as f64).round() as usize)
+ .collect()
+}
+
+pub fn render(f: &mut Frame, area: Rect, ring: &Ring, max: f64, color: Color, marker_row: Option<u16>) {
+ if area.width == 0 || area.height == 0 || max <= 0.0 {
+ return;
+ }
+ let rows = area.height as usize;
+ let cols = area.width as usize;
+ let samples: Vec<f64> = ring.samples().collect();
+ let skip = samples.len().saturating_sub(cols);
+ let lvls = levels(&samples[skip..], max, rows);
+
+ let mut buf = vec![vec![(' ', Style::default()); cols]; rows];
+ let mut set = |r: usize, c: usize, ch: char, st: Style| {
+ if r < rows && c < cols && buf[r][c].0 == ' ' {
+ buf[r][c] = (ch, st);
+ }
+ };
+
+ let st = Style::default().fg(color);
+ for (c, &l) in lvls.iter().enumerate() {
+ let prev_l = if c == 0 { l } else { lvls[c - 1] };
+ if prev_l == l {
+ set(l, c, '─', st);
+ } else {
+ // Value rise = row number falls (top row is max), so
+ // prev_l > l means the line steps up on screen.
+ let (upper, lower) = if prev_l < l { (l, prev_l) } else { (prev_l, l) };
+ set(upper, c, '╮', st);
+ set(lower, c, '╰', st);
+ for r in upper + 1..lower {
+ set(r, c, '│', st);
+ }
+ }
+ }
+
+ if let Some(mr) = marker_row {
+ let mr = (mr as usize).min(rows - 1);
+ for cell in buf[mr].iter_mut().take(cols) {
+ if cell.0 == ' ' {
+ *cell = ('┄', Style::default().fg(Color::Red));
+ }
+ }
+ }
+
+ let lines: Vec<Line> = buf
+ .into_iter()
+ .map(|row| {
+ Line::from(
+ row.into_iter()
+ .map(|(ch, sty)| Span::styled(ch.to_string(), sty))
+ .collect::<Vec<_>>(),
+ )
+ })
+ .collect();
+ f.render_widget(ratatui::text::Text::from(lines), area);
+}
\ No newline at end of file
diff --git a/src/ui.rs b/src/ui.rs
index 7596606..21e454a 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -7,11 +7,13 @@ 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;
@@ -22,6 +24,12 @@ const VCORE_MARGIN: f64 = 0.05;
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,
@@ -39,6 +47,10 @@ pub struct App {
/// (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.
@@ -48,6 +60,7 @@ fn dynamic_max(peak: f64, static_max: f64, margin: f64) -> f64 {
}
/// One physical core's row in the per-core section.
+#[derive(Clone)]
struct CoreRow {
label: String,
e_core: bool,
@@ -71,12 +84,16 @@ impl App {
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
@@ -99,6 +116,9 @@ impl App {
.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
@@ -159,6 +179,7 @@ impl App {
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);
@@ -282,148 +303,157 @@ fn fmt_opt_temp(v: Option<f64>) -> 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 w = f.area().width;
- let mut lines: Vec<Line> = Vec::new();
+ 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, 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);
+ }
- // ---- vCore hero ----
+ // ---- 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(),
};
- lines.push(Line::from(Span::styled(
- "vCore",
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
- )));
- // Bar maxima auto-scale: session all-time peak + margin, floored at the
- // static defaults. OC'ing past a default compresses the bar to fit.
- 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,
- );
-
- lines.push(meter_line(
- "",
- &hero_text,
- app.vcore.map(|v| v / vc_max),
- vc_color,
- w,
- Some(app.cfg.vcore_limit / vc_max),
- ));
- lines.push(Line::from(""));
- // ---- rails (config-mapped SIO inputs) ----
- let rail_rows: Vec<(String, Option<f64>)> = app
- .cfg
- .rails
- .iter()
- .filter(|r| !r.hide && r.input != "in0") // in0 shown as the hero
- .map(|r| (r.label.clone(), sensors::sio_input(&r.input)))
- .collect();
- if rail_rows.iter().any(|(_, v)| v.is_some()) {
- lines.push(Line::from(Span::styled(
- "Rails",
- Style::default().fg(Color::Cyan),
- )));
- for (label, v) in &rail_rows {
- let label = if label.is_empty() {
- "??".to_string()
- } else {
- label.clone()
- };
- let text = match v {
- Some(v) => format!("{:.3} V", v),
- None => "n/a".to_string(),
- };
- lines.push(meter_line(&label, &text, None, Color::Magenta, w, None));
+ if app.cfg.graphs && w >= GRAPHS_MIN_WIDTH {
+ let thirds = w / 3;
+ let mut x = area.x;
+ for (i, (label, text, ring, max, color, marker)) in [
+ ("vCore", hero_text.clone(), &app.vcore_graph, vc_max, vc_color,
+ Some(((app.cfg.vcore_limit / vc_max).clamp(0.0, 1.0) * GRAPH_ROWS as f64).round() as u16)),
+ ("Pkg power", fmt_opt_watts(app.package_watts), &app.watts_graph, watt_max, Color::Blue, None),
+ ("Pkg temp", fmt_opt_temp(app.package_temp), &app.temp_graph, TEMP_BAR_MAX,
+ 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;
}
- lines.push(Line::from(""));
- }
-
- // ---- package ----
- 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(""));
-
- // ---- cores: freq bar + temp number ----
- for (section, pred) in [("P-cores", false), ("E-cores", true)] {
- lines.push(Line::from(Span::styled(
- section,
- Style::default().fg(Color::Cyan),
- )));
- for core in app.cores.iter().filter(|c| c.e_core == pred) {
- 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, w, None));
+ } 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(""));
}
- lines.push(Line::from(""));
+ render_lines(f, bottom_area, &lines);
}
// ---- footer ----
- lines.push(Line::from(vec![
+ 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
+ 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(lines), f.area());
+ ]);
+ f.render_widget(ratatui::text::Text::from(footer), Rect { y: footer_y, height: 1, ..area });
}
pub fn run_tui() {
UI v2: dual-column P/E cores, nvtop-style package plots at bottom, responsive fallbacks, layout/graphs config keys, non-CPU rails hidden by default
f11482a45e13a56d410b376f30228d53f2dbbd71
josie <administrator@josie-c.com> · 2026-08-30T13:06 ·
browse files at this commit
parents:
030988f