diff --git a/src/config.rs b/src/config.rs
index 3de8226..a6da5c8 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -19,6 +19,8 @@ pub struct Config {
pub temp_crit: f64,
pub vcore_bar_max: f64,
pub power_bar_max: f64,
+ /// Peak-clock plot scale, kHz (P-core turbo ceiling, 4.5 GHz).
+ pub clock_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.
@@ -52,6 +54,7 @@ impl Default for Config {
temp_crit: 95.0,
vcore_bar_max: 1.50,
power_bar_max: 200.0,
+ clock_bar_max: 4_500_000.0,
layout: Layout::Auto,
graphs: true,
poll_ms: 250,
@@ -116,6 +119,7 @@ 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),
+ "clock_bar_max" => cfg.clock_bar_max = val.parse().unwrap_or(cfg.clock_bar_max),
"graphs" => cfg.graphs = val == "true",
"poll_ms" => cfg.poll_ms = val.parse().unwrap_or(cfg.poll_ms).max(50),
"graph_secs" => cfg.graph_secs = val.parse().unwrap_or(cfg.graph_secs).max(5),
diff --git a/src/plot.rs b/src/plot.rs
index 4f11b2d..78d6d9f 100644
--- a/src/plot.rs
+++ b/src/plot.rs
@@ -1,9 +1,10 @@
-//! 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.
-//! Style: continuous staircase (level `─`, vertical `│` joins — no corner
-//! glyphs; they masked peaks), y-axis labels on the left, time axis on the
-//! bottom (oldest left → 0s right), like nvtop's plot panes.
+//! Thin-line plot, re-implemented for ratatui (nvtop plot.c pattern studied
+//! in references/nvtop — GPL, code never copied). A `Ring` is a fixed-
+//! capacity sample buffer; the rightmost drawn column is the newest.
+//! Style: MSI Afterburner frametime-monitor look — one glyph per column,
+//! RIGHT-ANGLE corners (level `─`, corners `┐└` on falls / `┘┌` on rises,
+//! `│` between) so peaks/valleys read as sharp steps. Y-axis: max top-left,
+//! 0 bottom-left. No time axis (all stacked plots share one window).
use ratatui::{
Frame,
@@ -88,15 +89,14 @@ fn levels_hysteresis(samples: &[f64], max: f64, rows: usize) -> Vec<usize> {
out
}
-/// nvtop draw: y-axis labels overlay the left edge (e.g. 100/75/50/25/0),
-/// the staircase occupies the full area, time labels sit on the bottom row
-/// (rightmost = "0s", leftward = elapsed seconds of the window).
+/// Thin-line draw: y-axis labels overlay the left edge (max top-left,
+/// 0 bottom-left), the staircase occupies the full area. No time axis —
+/// stacked plots share one window, so x is time on every plot.
pub fn render(
f: &mut Frame,
area: Rect,
ring: &Ring,
window_ticks: usize,
- poll_ms: u64,
max: f64,
color: Color,
marker_row: Option<u16>,
@@ -154,11 +154,11 @@ pub fn render(
};
let st = Style::default().fg(color);
- // nvtop's connected staircase (plot.c lines 55-107, pattern only):
- // on a step, the corner glyphs *join* the horizontals — old level gets
- // a corner open toward the incoming line, new level a corner open
- // toward the outgoing line, '│' strictly between. Every horizontal run
- // therefore connects to the next through a corner, no gaps.
+ // Right-angle staircase (Afterburner frametime style): on a step the
+ // corner glyphs *join* the horizontals — old level gets a corner open
+ // toward the incoming line, new level a corner open toward the outgoing
+ // line, '│' strictly between. Falls: '┐' old / '└' land. Rises: '┘'
+ // old / '┌' land. Rounded ╮╰╯╭ are banned — spikes must read sharp.
for (c, &l) in lvls.iter().enumerate() {
if c == 0 {
set(l, c, '─', st);
@@ -168,18 +168,18 @@ pub fn render(
if prev == l {
set(l, c, '─', st);
} else if prev < l {
- // value fell: line steps DOWN screen. Old level: '╮' (opens
- // left toward incoming). New level: '╰' (opens right toward
+ // value fell: line steps DOWN screen. Old level: '┐' (opens
+ // left toward incoming). New level: '└' (opens right toward
// outgoing). '│' between.
- set(prev, c, '╮', st);
- set(l, c, '╰', st);
+ set(prev, c, '┐', st);
+ set(l, c, '└', st);
for r in prev + 1..l {
set(r, c, '│', st);
}
} else {
- // value rose: line steps UP screen. Old level: '╯', new: '╭'.
- set(prev, c, '╯', st);
- set(l, c, '╭', st);
+ // value rose: line steps UP screen. Old level: '┘', new: '┌'.
+ set(prev, c, '┘', st);
+ set(l, c, '┌', st);
for r in l + 1..prev {
set(r, c, '│', st);
}
@@ -197,10 +197,10 @@ pub fn render(
// ---- axis labels (overlay, drawn last so they win blanks) ----
let axis_st = Style::default().fg(Color::DarkGray);
- // y-axis: max and half-max at top/left and mid-left.
+ // y-axis: max top-left, 0 bottom-left (Afterburner pane style).
let y_labels = [
(0usize, format!("{:.0}", max)),
- (rows / 2, format!("{:.0}", max / 2.0)),
+ (rows - 1, "0".to_string()),
];
for (r, label) in y_labels {
if r < rows {
@@ -211,22 +211,6 @@ pub fn render(
}
}
}
- // x-axis on the bottom row: window length in seconds at the left,
- // "0s" at the right (nvtop: seconds count down to now).
- let total_s = (window_ticks as u64 * poll_ms / 1000).max(1);
- let t_label = format!("{}s", total_s);
- for (i, ch) in t_label.chars().enumerate() {
- if buf[rows - 1][i].0 == ' ' {
- buf[rows - 1][i] = (ch, axis_st);
- }
- }
- for (i, ch) in "0s".chars().enumerate() {
- let c = cols - 2 + i;
- if c < cols && buf[rows - 1][c].0 == ' ' {
- buf[rows - 1][c] = (ch, axis_st);
- }
- }
-
let lines: Vec<Line> = buf
.into_iter()
.map(|row| {
diff --git a/src/ui.rs b/src/ui.rs
index b2f10bf..eadcccd 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -48,6 +48,8 @@ pub struct App {
vcore_graph: Ring,
watts_graph: Ring,
temp_graph: Ring,
+ /// Peak clock (max freq across all cores per tick) graph history.
+ clock_graph: Ring,
/// Settings pane open (F2); F10 commits+writes conf, Esc closes.
settings_open: bool,
/// Which settings row the cursor is on (index into SETTINGS_ROWS).
@@ -122,6 +124,7 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
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));
}
_ => {
// Scale: toggle auto/fixed. Bare Enter toggles; also accept
@@ -182,6 +185,7 @@ impl App {
vcore_graph: Ring::new(cap),
watts_graph: Ring::new(cap),
temp_graph: Ring::new(cap),
+ clock_graph: Ring::new(cap),
settings_open: false,
settings_row: 0,
edit_buf: String::new(),
@@ -263,6 +267,11 @@ impl App {
.collect();
self.cores
.sort_by_key(|c| (c.e_core, c.label[5..].parse::<u64>().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);
+ }
}
// Watts = energy delta over the tick (poll cadence IS the sample
@@ -449,6 +458,10 @@ fn render_lines(f: &mut Frame, area: Rect, lines: &[Line<'static>]) {
}
}
+/// One stacked plot: (label, live text, ring, scale max, line color,
+/// optional marker row).
+type PlotSpec<'a> = (&'a str, String, &'a Ring, f64, Color, Option<u16>);
+
pub fn draw(f: &mut Frame, app: &App) {
let area = f.area();
let w = area.width;
@@ -458,18 +471,19 @@ pub fn draw(f: &mut Frame, app: &App) {
config::Layout::Auto => w >= DUAL_COLUMN_MIN_WIDTH,
};
- // ---- vertical budget: graphs get 2/3 of the height, cores 1/3 ----
+ // ---- vertical budget: 4 stacked plots get 2/3 of the height, cores 1/3 ----
let footer_h = 1u16;
let graph_rows: u16 = if app.cfg.graphs && w >= GRAPHS_MIN_WIDTH {
- // 2/3 of rows minus caption + separator, floored at 3 rows.
- ((area.height * 2 / 3)
- .saturating_sub(2))
- .max(3)
+ // 2/3 of rows split across 4 plots (each = caption + graph rows),
+ // floored at 2 rows so a thin line still reads.
+ ((area.height * 2 / 3) / 4)
+ .saturating_sub(1)
+ .max(2)
} else {
0
};
let bottom_h: u16 = if graph_rows > 0 {
- graph_rows + 1 // caption line above the graph
+ 4 * (graph_rows + 1) // 4 plots, caption + graph each
} else {
2 // pkg power + pkg temp meter rows
};
@@ -515,8 +529,6 @@ pub fn draw(f: &mut Frame, app: &App) {
};
if app.cfg.graphs && w >= GRAPHS_MIN_WIDTH {
- let thirds = w / 3;
- let mut x = area.x;
// Graphs scale to the WINDOW's max (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.
@@ -527,32 +539,46 @@ pub fn draw(f: &mut Frame, app: &App) {
// Fixed scale (settings pane / graph_scale_fixed): bar_max limits,
// never rescaled mid-benchmark — peaks clip at the top instead of
// the whole trace compacting. Auto: window peak + margin.
- let (vc_graph_max, watt_graph_max, temp_graph_max) = if app.cfg.graph_scale_fixed {
- (app.cfg.vcore_bar_max, app.cfg.power_bar_max, TEMP_BAR_MAX)
- } else {
- (
- ring_max(&app.vcore_graph, 1.0),
- ring_max(&app.watts_graph, 10.0),
- ring_max(&app.temp_graph, 50.0),
- )
+ let (vc_graph_max, watt_graph_max, temp_graph_max, clock_graph_max) =
+ if app.cfg.graph_scale_fixed {
+ (
+ app.cfg.vcore_bar_max,
+ app.cfg.power_bar_max,
+ TEMP_BAR_MAX,
+ app.cfg.clock_bar_max,
+ )
+ } else {
+ (
+ ring_max(&app.vcore_graph, 1.0),
+ ring_max(&app.watts_graph, 10.0),
+ ring_max(&app.temp_graph, 50.0),
+ ring_max(&app.clock_graph, 1_000_000.0),
+ )
+ };
+ let clock_text = match app.cores.iter().filter_map(|c| c.freq_khz).max() {
+ Some(k) => format!("{:.2} GHz", k as f64 / 1_000_000.0),
+ None => "n/a".to_string(),
};
- for (i, (label, text, ring, max, color, marker)) in [
+ // 4 full-width plots stacked top→bottom, sharing one time axis.
+ let plots: [PlotSpec; 4] = [
("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, temp_graph_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 };
+ ("Peak clock", clock_text, &app.clock_graph, clock_graph_max, Color::Green, None),
+ ];
+ let mut y = bottom_y;
+ for (label, text, ring, max, color, marker) in plots {
+ let rect = Rect { x: area.x, y, width: w, height: graph_rows + 1 };
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, ticks, app.cfg.poll_ms, max, color, marker);
- x += thirds;
+ plot::render(f, Rect { y: rect.y + 1, height: graph_rows, ..rect }, ring, ticks, max, color, marker);
+ y += graph_rows + 1;
}
} else {
let mut lines = vec![
Graphs: MSI Afterburner style — 4 full-width stacked plots (vCore/power/temp/peak clock) sharing one time axis, right-angle corners ┐└┘┌, y-axis max/0, no time axis; peak-clock metric + clock_bar_max config key
7e801e55c760b926a5b08ab4107d6e05c6961d07
josie <administrator@josie-c.com> · 2026-08-31T18:21 ·
browse files at this commit
parents:
afeaffa