josie / alder-tools

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

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![