josie / alder-tools

Settings: graph window accepts minutes (30m/2m30s), graph scale auto/fixed (fixed pins bar_max, no mid-benchmark rescale); allow m/s in edit buffer; bare-Enter toggle fix

afeaffadbc7ac432f885cd7bd5164f1785b6eab9
josie <administrator@josie-c.com> · 2026-08-30T14:13 · browse files at this commit

parents: c561d25

diff --git a/adlermon.conf b/adlermon.conf
index 56ee7df..03dfd9c 100644
--- a/adlermon.conf
+++ b/adlermon.conf
@@ -30,7 +30,8 @@ rail.in12.label = Fixed 1.14
 rail.in13.hide  = true
 rail.in14.label = Fixed 1.5V
 poll_ms = 250
-graph_secs = 3600
+graph_secs = 1800
+graph_scale_fixed = true
 graphs = true
 layout = auto
 vcore_limit = 1.403
diff --git a/src/config.rs b/src/config.rs
index b5066f9..3de8226 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -26,8 +26,11 @@ pub struct Config {
     /// Tick / sample period, ms.
     pub poll_ms: u64,
     /// Graph window (seconds of history shown). History kept is capped by
-    /// the ring size (see plot::RING_CAP).
+    /// the ring size (see plot::Ring::new).
     pub graph_secs: u64,
+    /// Graph vertical scale: auto (window peak + margin) or fixed (the
+    /// bar_max limits below — no rescaling mid-benchmark).
+    pub graph_scale_fixed: bool,
     /// Path the config was loaded from (save-back target; None = never
     /// loaded, defaults in effect).
     pub loaded_path: Option<std::path::PathBuf>,
@@ -53,6 +56,7 @@ impl Default for Config {
             graphs: true,
             poll_ms: 250,
             graph_secs: 120,
+            graph_scale_fixed: false,
             loaded_path: None,
         }
     }
@@ -115,6 +119,7 @@ fn apply_line(cfg: &mut Config, line: &str) {
         "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),
+        "graph_scale_fixed" => cfg.graph_scale_fixed = val == "true",
         "layout" => {
             cfg.layout = match val {
                 "single" => Layout::Single,
@@ -174,9 +179,10 @@ 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 = {}\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}\n",
         cfg.poll_ms,
         cfg.graph_secs,
+        cfg.graph_scale_fixed,
         cfg.graphs,
         match cfg.layout {
             Layout::Auto => "auto",
@@ -313,4 +319,17 @@ mod tests {
         assert_eq!(out.matches("poll_ms").count(), 1);
         assert_eq!(out.matches("graphs").count(), 1);
     }
+
+    #[test]
+    fn parses_scale_fixed() {
+        let mut cfg = Config::default();
+        apply_line(&mut cfg, "graph_scale_fixed = true");
+        assert!(cfg.graph_scale_fixed);
+    }
+
+    #[test]
+    fn settings_text_includes_scale() {
+        let cfg = Config::default();
+        assert!(settings_text(&cfg).contains("graph_scale_fixed = false"));
+    }
 }
diff --git a/src/ui.rs b/src/ui.rs
index 71e68df..b2f10bf 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -59,15 +59,49 @@ pub struct App {
 }
 
 /// Settings-pane rows: (label, config field edited, unit).
-const SETTINGS_ROWS: [&str; 2] = ["Poll period", "Graph window"];
+const SETTINGS_ROWS: [&str; 3] = ["Poll period", "Graph window", "Graph scale"];
 
 fn setting_value(app: &App, row: usize) -> String {
     match row {
         0 => format!("{} ms", app.cfg.poll_ms),
-        _ => format!("{} s", app.cfg.graph_secs),
+        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)
+            }
+        }
+        _ => {
+            if app.cfg.graph_scale_fixed {
+                "fixed".to_string()
+            } else {
+                "auto".to_string()
+            }
+        }
     }
 }
 
+/// 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();
+    if let Some(mins) = raw.strip_suffix('m') {
+        return mins
+            .trim()
+            .parse::<u64>()
+            .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::<u64>().map_err(|_| format!("'{raw}' is not a number"))
+}
+
 fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
     match row {
         0 => {
@@ -79,18 +113,26 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
             }
             app.cfg.poll_ms = v;
         }
-        _ => {
-            let v: u64 = raw
-                .parse()
-                .map_err(|_| format!("'{}' is not a number", raw))?;
+        1 => {
+            let v = parse_window_secs(raw)?;
             if !(5..=3600).contains(&v) {
-                return Err("graph_secs must be 5..3600".into());
+                return Err("window must be 5s..60m".into());
             }
             app.cfg.graph_secs = v;
- app.vcore_graph = Ring::new(ring_capacity(&app.cfg));
+            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));
         }
+        _ => {
+            // 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()),
+            }
+        }
     }
     Ok(())
 }
@@ -482,14 +524,23 @@ pub fn draw(f: &mut Frame, app: &App) {
         let ring_max = |ring: &Ring, floor: f64| -> f64 {
             (ring.max_window(ticks) * (1.0 + SCALE_MARGIN)).max(floor)
         };
-        let vc_graph_max = ring_max(&app.vcore_graph, 1.0);
-        let watt_graph_max = ring_max(&app.watts_graph, 10.0);
+        // 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),
+            )
+        };
         for (i, (label, text, ring, max, color, marker)) in [
             ("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,
-                ring_max(&app.temp_graph, 0.0).max(50.0),
+            ("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 };
@@ -587,9 +638,6 @@ fn settings_key(app: &mut App, key: KeyCode) -> Result<(), String> {
             app.edit_buf.clear();
         }
         KeyCode::Enter => {
-            if app.edit_buf.is_empty() {
-                return Ok(());
-            }
             let raw = std::mem::take(&mut app.edit_buf);
             setting_absorb(app, app.settings_row, &raw)?;
         }
@@ -600,7 +648,9 @@ 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() => app.edit_buf.push(c),
+        KeyCode::Char(c) if c.is_ascii_digit() || c == 'm' || c == 's' => {
+            app.edit_buf.push(c)
+        }
         _ => {}
     }
     Ok(())