josie / alder-tools

Graphs: per-graph min/max in settings pane, boxed plots w/ keys+peaks above, axis labels on border rows, auto window-zoom scale, cores reserve height w/ class-meter collapse, settings pane clears screen

- Settings pane (now 11 rows): per-graph y bounds — vCore/Clock/Power/Temp min+max (clock edited in GHz, stored kHz); written back via F10
- plot::render takes min+max and returns trace width; Ring gains min_window()/peak()
- Boxed graphs (DarkGray border), key row above each box (label+live left, 'peak X' right-aligned to trace), y-axis max/min OUTSIDE on border rows
- Window fix: visible span = min(box width, graph_ticks), right-pinned
- Auto scale zooms to window min/max + 1-row pad (fills interior w/o touching borders); fixed pins conf min/max exactly
- Cores keep full height (graphs flex); too short -> one meter per class (class max freq/temp), then 1-row traces, then meters
- Settings pane: package zone skips rendering + Clear behind (diff renderer drew traces through the overlay), width 52
- temp_max config key replaces hardcoded 100C; ratatui separator-span gotcha documented

12 tests, clippy clean (1 pre-existing warning); pty-verified 45-20 rows x 120/110/66 cols, idle + yes-load, fixed+auto scale, settings round-trip

23330405dab04aace0f1dc5d9c4e24eeb71f7938
josie <administrator@josie-c.com> · 2026-08-31T19:38 · browse files at this commit

parents: f61cc80

diff --git a/README.md b/README.md
index e0836cb..8b1ed58 100644
--- a/README.md
+++ b/README.md
@@ -28,8 +28,16 @@ target an ASRock Z690M ITX/ax (nct6798).
 vcore_limit   = 1.403   # safety limit shown as marker on the vCore bar
 temp_warn     = 80.0
 temp_crit     = 95.0
+
+# Per-graph y scales (also editable in the settings pane, F2)
+vcore_min     = 0.0
 vcore_bar_max = 1.50
+clock_min     = 0          # kHz
+clock_bar_max = 4500000
+power_min     = 0.0
 power_bar_max = 200.0
+temp_min      = 0
+temp_max      = 100
 
 rail.in0.label = CPU Vcore   # hwmon input label
 rail.in4.label = VCCIO?
diff --git a/adlermon.conf b/adlermon.conf
index 328e1d7..8cd041d 100644
--- a/adlermon.conf
+++ b/adlermon.conf
@@ -31,9 +31,17 @@ rail.in13.hide  = true
 rail.in14.label = Fixed 1.5V
 poll_ms = 250
 graph_secs = 300
-graph_scale_fixed = false
+graph_scale_fixed = true
 graphs = true
 layout = auto
 vcore_limit = 1.403
 temp_warn = 80.0
 temp_crit = 95.0
+vcore_min = 0.500
+vcore_bar_max = 1.500
+clock_min = 0
+clock_bar_max = 5200000
+power_min = 0.0
+power_bar_max = 280.0
+temp_min = 20
+temp_max = 100
diff --git a/src/config.rs b/src/config.rs
index c667c60..81bcc62 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -21,6 +21,13 @@ pub struct Config {
     pub power_bar_max: f64,
     /// Peak-clock plot scale, kHz (P-core turbo ceiling, 4.5 GHz).
     pub clock_bar_max: f64,
+    /// Temp graph scale, °C (shared by the graph scale and meter max).
+    pub temp_max: f64,
+    /// Per-graph scale floors (y-min; 0.0 = bottom of the box).
+    pub vcore_min: f64,
+    pub clock_min: f64,
+    pub power_min: f64,
+    pub temp_min: f64,
     /// Core-column arrangement: auto (2 cols if wide), single, or dual.
     pub layout: Layout,
     /// Show nvtop-style line graphs in the package zone.
@@ -55,6 +62,11 @@ impl Default for Config {
             vcore_bar_max: 1.50,
             power_bar_max: 200.0,
             clock_bar_max: 4_500_000.0,
+            temp_max: 100.0,
+            vcore_min: 0.0,
+            clock_min: 0.0,
+            power_min: 0.0,
+            temp_min: 0.0,
             layout: Layout::Auto,
             graphs: true,
             poll_ms: 250,
@@ -120,6 +132,11 @@ fn apply_line(cfg: &mut Config, line: &str) {
         "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),
+        "temp_max" => cfg.temp_max = val.parse().unwrap_or(cfg.temp_max),
+        "vcore_min" => cfg.vcore_min = val.parse().unwrap_or(cfg.vcore_min),
+        "clock_min" => cfg.clock_min = val.parse().unwrap_or(cfg.clock_min),
+        "power_min" => cfg.power_min = val.parse().unwrap_or(cfg.power_min),
+        "temp_min" => cfg.temp_min = val.parse().unwrap_or(cfg.temp_min),
         "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),
@@ -183,7 +200,7 @@ pub fn load() -> Config {
 /// the loaded conf path (F10); rail map and limits stay as-is on disk.
 pub fn settings_text(cfg: &Config) -> String {
     format!(
-        "poll_ms = {}\ngraph_secs = {}\ngraph_scale_fixed = {}\ngraphs = {}\nlayout = {}\nvcore_limit = {:.3}\ntemp_warn = {:.1}\ntemp_crit = {:.1}\n",
+        "poll_ms = {}\ngraph_secs = {}\ngraph_scale_fixed = {}\ngraphs = {}\nlayout = {}\nvcore_limit = {:.3}\ntemp_warn = {:.1}\ntemp_crit = {:.1}\nvcore_min = {:.3}\nvcore_bar_max = {:.3}\nclock_min = {:.0}\nclock_bar_max = {:.0}\npower_min = {:.1}\npower_bar_max = {:.1}\ntemp_min = {:.0}\ntemp_max = {:.0}\n",
         cfg.poll_ms,
         cfg.graph_secs,
         cfg.graph_scale_fixed,
@@ -196,6 +213,14 @@ pub fn settings_text(cfg: &Config) -> String {
         cfg.vcore_limit,
         cfg.temp_warn,
         cfg.temp_crit,
+        cfg.vcore_min,
+        cfg.vcore_bar_max,
+        cfg.clock_min,
+        cfg.clock_bar_max,
+        cfg.power_min,
+        cfg.power_bar_max,
+        cfg.temp_min,
+        cfg.temp_max,
     )
 }
 
diff --git a/src/plot.rs b/src/plot.rs
index 1e541f5..bd260a5 100644
--- a/src/plot.rs
+++ b/src/plot.rs
@@ -23,6 +23,8 @@ pub struct Ring {
     data: Vec<f64>,
     start: usize,
     len: usize,
+    /// All-time (session) maximum pushed.
+    peak: f64,
 }
 
 impl Ring {
@@ -31,6 +33,7 @@ impl Ring {
             data: vec![0.0; capacity],
             start: 0,
             len: 0,
+            peak: f64::MIN,
         }
     }
 
@@ -38,6 +41,9 @@ impl Ring {
         let cap = self.data.len();
         let end = (self.start + self.len) % cap;
         self.data[end] = v;
+        if v > self.peak {
+            self.peak = v;
+        }
         if self.len < cap {
             self.len += 1;
         } else {
@@ -45,6 +51,11 @@ impl Ring {
         }
     }
 
+    /// Session all-time max (f64::MIN before the first push).
+    pub fn peak(&self) -> f64 {
+        self.peak
+    }
+
     pub fn samples_window(&self, window_ticks: usize) -> impl Iterator<Item = f64> + '_ {
         let skip = self.len.saturating_sub(window_ticks);
         (skip..self.len).map(move |i| self.data[(self.start + i) % self.data.len()])
@@ -53,15 +64,20 @@ impl Ring {
     pub fn max_window(&self, window_ticks: usize) -> f64 {
         self.samples_window(window_ticks).fold(f64::MIN, f64::max)
     }
+
+    pub fn min_window(&self, window_ticks: usize) -> f64 {
+        self.samples_window(window_ticks).fold(f64::MAX, f64::min)
+    }
 }
 
 /// Plain rounding map (used by tests; hysteresis variant is used in render).
 #[allow(dead_code)]
-fn levels(samples: &[f64], max: f64, rows: usize) -> Vec<usize> {
+fn levels(samples: &[f64], min: f64, max: f64, rows: usize) -> Vec<usize> {
     // Row 0 = top of the plot = max value.
+    let span = max - min;
     samples
         .iter()
-        .map(|&v| (((1.0 - v / max).clamp(0.0, 1.0)) * (rows - 1) as f64).round() as usize)
+        .map(|&v| (((1.0 - (v - min) / span).clamp(0.0, 1.0)) * (rows - 1) as f64).round() as usize)
         .collect()
 }
 
@@ -69,8 +85,9 @@ fn levels(samples: &[f64], max: f64, rows: usize) -> Vec<usize> {
 /// map flap between two rows (`─│─│─│` comb). Only move off the previous
 /// row when the continuous level is nearer to the new row by a decisive
 /// margin, so noise rides out and real steps still track.
-fn levels_hysteresis(samples: &[f64], max: f64, rows: usize) -> Vec<usize> {
-    let cont = |v: f64| (1.0 - v / max).clamp(0.0, 1.0) * (rows - 1) as f64;
+fn levels_hysteresis(samples: &[f64], min: f64, max: f64, rows: usize) -> Vec<usize> {
+    let span = max - min;
+    let cont = |v: f64| (1.0 - (v - min) / span).clamp(0.0, 1.0) * (rows - 1) as f64;
     let mut out: Vec<usize> = Vec::with_capacity(samples.len());
     let mut last: Option<f64> = None; // continuous position of drawn row
     for &v in samples {
@@ -93,21 +110,25 @@ fn levels_hysteresis(samples: &[f64], max: f64, rows: usize) -> Vec<usize> {
 }
 
 /// 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 —
+/// min bottom-left), the staircase occupies the full area. No time axis —
 /// stacked plots share one window, so x is time on every plot. Scrolling
 /// view: the last `cols` ticks fill the width 1:1 (newest at the right
 /// edge), older history scrolls off the left — no decimation or smoothing.
+/// Returns the number of columns actually used by the trace (0 = nothing
+/// drawn). The caller renders y-axis labels OUTSIDE this area to the left.
+#[allow(clippy::too_many_arguments)]
 pub fn render(
     f: &mut Frame,
     area: Rect,
     ring: &Ring,
     window_ticks: usize,
+    min: f64,
     max: f64,
     color: Color,
     marker_row: Option<u16>,
-) {
-    if area.width < 8 || area.height < 3 || max <= 0.0 {
-        return;
+) -> u16 {
+    if area.width < 4 || area.height < 2 || max <= min {
+        return 0;
     }
     let rows = area.height as usize;
     let cols = area.width as usize;
@@ -117,10 +138,11 @@ pub fn render(
     // the ring keeps the full window for the peak counter and scale.
     let samples: Vec<f64> = ring.samples_window(window_ticks).collect();
     if samples.is_empty() {
-        return;
+        return 0;
     }
     let skip = samples.len().saturating_sub(cols);
-    let lvls = levels_hysteresis(&samples[skip..], max, rows);
+    let lvls = levels_hysteresis(&samples[skip..], min, max, rows);
+    let used = (cols - skip.min(cols)) as u16; // columns the trace occupies
 
     let mut buf = vec![vec![(' ', Style::default()); cols]; rows];
     let mut set = |r: usize, c: usize, ch: char, st: Style| {
@@ -171,22 +193,6 @@ pub fn render(
         }
     }
 
-    // ---- axis labels (overlay, drawn last so they win blanks) ----
-    let axis_st = Style::default().fg(Color::DarkGray);
-    // y-axis: max top-left, 0 bottom-left (Afterburner pane style).
-    let y_labels = [
-        (0usize, format!("{:.0}", max)),
-        (rows - 1, "0".to_string()),
-    ];
-    for (r, label) in y_labels {
-        if r < rows {
-            for (i, ch) in label.chars().take(4).enumerate() {
-                if buf[r][i].0 == ' ' {
-                    buf[r][i] = (ch, axis_st);
-                }
-            }
-        }
-    }
     let lines: Vec<Line> = buf
         .into_iter()
         .map(|row| {
@@ -198,6 +204,17 @@ pub fn render(
         })
         .collect();
     f.render_widget(ratatui::text::Text::from(lines), area);
+    used
+}
+
+/// Compact axis label: integers stay short ("200", "0"), fractional values
+/// keep up to 3 decimals, trimmed ("0.5", "1.25").
+pub fn fmt_axis(v: f64) -> String {
+    if v.fract() == 0.0 {
+        return format!("{v:.0}");
+    }
+    let s = format!("{v:.3}");
+    s.trim_end_matches('0').trim_end_matches('.').to_string()
 }
 #[cfg(test)]
 mod tests {
@@ -205,24 +222,27 @@ mod tests {
 
     #[test]
     fn levels_map_value_to_rows() {
-        assert_eq!(levels(&[10.0, 0.0], 10.0, 5), [0, 4]);
-        assert_eq!(levels(&[5.0], 10.0, 5), [2]);
+        assert_eq!(levels(&[10.0, 0.0], 0.0, 10.0, 5), [0, 4]);
+        assert_eq!(levels(&[5.0], 0.0, 10.0, 5), [2]);
+        // Non-zero floor: min sits at the bottom row, span maps over min..max.
+        assert_eq!(levels(&[10.0, 5.0, 20.0], 5.0, 20.0, 5), [3, 4, 0]);
     }
 
     #[test]
     fn hysteresis_kills_boundary_flapping() {
         // Values hovering right at a row boundary: plain rounding would
         // alternate; hysteresis must hold the row until a decisive move.
+        let min = 0.0;
         let max = 100.0;
         let rows = 10;
         // 49.9..50.1 straddles rows-1/2 boundary (cont ~4.5).
         let vals = [50.4, 49.6, 50.4, 49.6, 50.4, 49.6];
-        let lv = levels_hysteresis(&vals, max, rows);
+        let lv = levels_hysteresis(&vals, min, max, rows);
         let all_same = lv.iter().all(|&r| r == lv[0]);
         assert!(all_same, "flapped: {:?}", lv);
         // A real step still tracks: push decisively past the margin.
         let vals2 = [50.0, 50.0, 70.0, 70.0];
-        let lv2 = levels_hysteresis(&vals2, max, rows);
+        let lv2 = levels_hysteresis(&vals2, min, max, rows);
         assert!(lv2[3] < lv2[1], "did not step up on real change: {:?}", lv2);
     }
 
@@ -238,5 +258,7 @@ mod tests {
         let got2: Vec<f64> = r.samples_window(2).collect();
         assert_eq!(got2, vec![5.0, 6.0]);
         assert_eq!(r.max_window(2), 6.0);
+        assert_eq!(r.min_window(2), 5.0);
+        assert_eq!(r.min_window(4), 3.0);
     }
 }
\ No newline at end of file
diff --git a/src/ui.rs b/src/ui.rs
index ef5aa68..494849f 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -21,12 +21,16 @@ const PEAK_WINDOW: Duration = Duration::from_secs(5 * 60);
 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).
+/// 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,
@@ -40,6 +44,8 @@ pub struct App {
     /// Highest pkg watts ever seen this session (auto-scale anchor)
     watts_all_time_peak: f64,
     package_temp: Option<f64>,
+    /// 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<CoreRow>,
@@ -65,7 +71,19 @@ pub struct App {
 }
 
 /// Settings-pane rows: (label, config field edited, unit).
-const SETTINGS_ROWS: [&str; 3] = ["Poll period", "Graph window", "Graph scale"];
+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 {
@@ -79,16 +97,33 @@ fn setting_value(app: &App, row: usize) -> String {
                 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<u64, String> {
     let raw = raw.trim().to_ascii_lowercase();
@@ -130,7 +165,7 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
             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() {
@@ -140,10 +175,35 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
                 _ => 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<f64>, unit: &str) -> Result<f64, String> {
+    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)
@@ -184,6 +244,7 @@ impl App {
             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),
@@ -227,6 +288,7 @@ impl App {
                 .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
@@ -466,9 +528,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>);
+/// 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<u16>, f64);
 
 pub fn draw(f: &mut Frame, app: &App) {
     let area = f.area();
@@ -479,29 +542,44 @@ pub fn draw(f: &mut Frame, app: &App) {
         config::Layout::Auto => w >= DUAL_COLUMN_MIN_WIDTH,
     };
 
-    // ---- vertical budget: 4 stacked plots get 2/3 of the height, cores 1/3 ----
+    // ---- 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 graph_rows: u16 = if app.cfg.graphs && w >= GRAPHS_MIN_WIDTH {
-        // 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)
+    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 {
-        0
+        (
+            compact_h,
+            (area.height.saturating_sub(compact_h + footer_h + 1) / 4).saturating_sub(3),
+        )
     };
-    let bottom_h: u16 = if graph_rows > 0 {
-        4 * (graph_rows + 1) // 4 plots, caption + graph each
+    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_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 ----
+    // Shorter than the full readout → one meter per class (max across
+    // that class's cores) instead of clipped per-core rows.
     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 {
@@ -509,16 +587,51 @@ pub fn draw(f: &mut Frame, app: &App) {
     } 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;
+    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);
         }
-        let lines = core_section_lines(title, &cores, app, rect.width);
-        render_lines(f, rect, &lines);
     }
 
     // ---- package zone (bottom) ----
@@ -530,74 +643,178 @@ pub fn draw(f: &mut Frame, app: &App) {
         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(),
+    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 app.cfg.graphs && w >= GRAPHS_MIN_WIDTH {
-        // 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.
-        // Scrolling view: the plot shows the last `w` ticks (one per column,
-        // newest at the right edge); the ring keeps the full graph_secs
-        // history for the peak counter and scale.
-        let ticks = w as usize;
+    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(graph_ticks(&app.cfg)) * (1.0 + SCALE_MARGIN)).max(floor)
+            (ring.max_window(ring_ticks) * (1.0 + SCALE_MARGIN)).max(floor)
         };
-        // 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, clock_graph_max) =
+        // 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_bar_max,
-                    app.cfg.power_bar_max,
-                    TEMP_BAR_MAX,
-                    app.cfg.clock_bar_max,
+                    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 {
-                (
-                    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 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)
             };
-        let clock_text = match (app.clock_khz, app.clock_all_time_peak) {
-            (Some(k), p) if p > 0 => format!(
-                "{:.2} GHz  peak {:.2}",
-                k as f64 / 1_000_000.0,
-                p as f64 / 1_000_000.0
-            ),
-            (Some(k), _) => format!("{:.2} GHz", k as f64 / 1_000_000.0),
-            (None, _) => "n/a".to_string(),
+        // 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)
         };
-        // 4 full-width plots stacked top→bottom, sharing one time axis.
+        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(), &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)),
-            ("Peak clock", clock_text, &app.clock_graph, clock_graph_max, Color::Green, None),
-            ("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),
+            ("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, 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)),
+        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(caption), Rect { height: 1, ..rect });
-            plot::render(f, Rect { y: rect.y + 1, height: graph_rows, ..rect }, ring, ticks, max, color, marker);
-            y += graph_rows + 1;
+            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 {
+    } 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)),
@@ -606,7 +823,7 @@ pub fn draw(f: &mut Frame, app: &App) {
             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(|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(""));
         }
@@ -630,11 +847,12 @@ pub fn draw(f: &mut Frame, app: &App) {
 }
 
 fn draw_settings(f: &mut Frame, area: Rect, app: &App) {
-    let h = SETTINGS_ROWS.len() as u16 + 4; // title + rows + hint + margin
-    let w = 44u16.min(area.width);
+    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),
@@ -691,7 +909,7 @@ fn settings_key(app: &mut App, key: KeyCode) -> Result<(), String> {
             app.settings_open = false;
             app.edit_buf.clear();
         }
-        KeyCode::Char(c) if c.is_ascii_digit() || c == 'm' || c == 's' => {
+        KeyCode::Char(c) if c.is_ascii_digit() || matches!(c, '.' | 'm' | 's') => {
             app.edit_buf.push(c)
         }
         _ => {}