josie / alder-tools

Settings pane (F2/F10): live poll_ms + graph_secs editing, conf save-back, windowed graph scaling; pty round-trip verified

ca8702ab54fd09c4252603066ff102ef7ade4f71
josie <administrator@josie-c.com> · 2026-08-30T13:22 · browse files at this commit

parents: 00b0cd7

diff --git a/src/config.rs b/src/config.rs
index 5d136df..b5066f9 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -23,6 +23,14 @@ pub struct Config {
     pub layout: Layout,
     /// Show nvtop-style line graphs in the package zone.
     pub graphs: bool,
+    /// 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).
+    pub graph_secs: u64,
+    /// Path the config was loaded from (save-back target; None = never
+    /// loaded, defaults in effect).
+    pub loaded_path: Option<std::path::PathBuf>,
 }
 
 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -43,6 +51,9 @@ impl Default for Config {
             power_bar_max: 200.0,
             layout: Layout::Auto,
             graphs: true,
+            poll_ms: 250,
+            graph_secs: 120,
+            loaded_path: None,
         }
     }
 }
@@ -102,6 +113,8 @@ 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),
         "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),
         "layout" => {
             cfg.layout = match val {
                 "single" => Layout::Single,
@@ -150,12 +163,75 @@ pub fn load() -> Config {
             for line in text.lines() {
                 apply_line(&mut cfg, line);
             }
+            cfg.loaded_path = Some(path);
             break;
         }
     }
     cfg
 }
 
+/// Serialize the settings editable in the settings pane. Written back to
+/// 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",
+        cfg.poll_ms,
+        cfg.graph_secs,
+        cfg.graphs,
+        match cfg.layout {
+            Layout::Auto => "auto",
+            Layout::Single => "single",
+            Layout::Dual => "dual",
+        },
+        cfg.vcore_limit,
+        cfg.temp_warn,
+        cfg.temp_crit,
+    )
+}
+
+/// Replace the values of the settings-pane keys in conf text (append the
+/// block if a key is missing). Returns the new file content.
+pub fn update_conf_text(text: &str, cfg: &Config) -> String {
+    let wanted = settings_text(cfg);
+    let mut out_lines: Vec<String> = Vec::new();
+    let mut seen: Vec<String> = Vec::new();
+    for line in text.lines() {
+        let key = line.split('#').next().unwrap_or("").trim();
+        let key = key.split_once('=').map(|(k, _)| k.trim().to_string());
+        if let Some(k) = key {
+            if wanted
+                .lines()
+                .any(|w| w.split_once('=').map(|(wk, _)| wk.trim() == k).unwrap_or(false))
+                && !seen.contains(&k)
+            {
+                // First occurrence of a managed key: emit the fresh block
+                // and mark every block key seen (the block covers them all).
+                for w in wanted.lines() {
+                    out_lines.push(w.to_string());
+                    if let Some((wk, _)) = w.split_once('=') {
+                        seen.push(wk.trim().to_string());
+                    }
+                }
+                continue;
+            }
+            if seen.contains(&k) {
+                continue; // subsequent dup of a managed key
+            }
+        }
+        out_lines.push(line.to_string());
+    }
+    let mut out = out_lines.join("\n");
+    for w in wanted.lines() {
+        let wk = w.split_once('=').unwrap().0.trim();
+        if !seen.iter().any(|s| s == wk) {
+            out.push('\n');
+            out.push_str(w);
+        }
+    }
+    out.push('\n');
+    out
+}
+
 fn dirs_home() -> Option<std::path::PathBuf> {
     std::env::var_os("HOME").map(std::path::PathBuf::from)
 }
@@ -208,4 +284,33 @@ mod tests {
         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"]);
     }
+
+    #[test]
+    fn parses_poll_and_graph_window() {
+        let mut cfg = Config::default();
+        apply_line(&mut cfg, "poll_ms = 500");
+        apply_line(&mut cfg, "graph_secs = 300");
+        assert_eq!(cfg.poll_ms, 500);
+        assert_eq!(cfg.graph_secs, 300);
+        apply_line(&mut cfg, "poll_ms = 1"); // floor 50
+        assert_eq!(cfg.poll_ms, 50);
+    }
+
+    #[test]
+    fn updates_conf_in_place() {
+        let text = "# comment\npoll_ms = 250\ngraph_secs = 120\nrail.in0.label = CPU Vcore\ngraphs = true\n";
+        let mut cfg = Config::default();
+        cfg.poll_ms = 500;
+        cfg.graph_secs = 300;
+        cfg.graphs = false;
+        let out = update_conf_text(text, &cfg);
+        assert!(out.contains("poll_ms = 500"));
+        assert!(out.contains("graph_secs = 300"));
+        assert!(out.contains("graphs = false"));
+        assert!(out.contains("rail.in0.label = CPU Vcore")); // untouched
+        assert!(out.starts_with("# comment")); // untouched
+        // Each managed key appears exactly once
+        assert_eq!(out.matches("poll_ms").count(), 1);
+        assert_eq!(out.matches("graphs").count(), 1);
+    }
 }
diff --git a/src/plot.rs b/src/plot.rs
index 70a13b6..c7cad8b 100644
--- a/src/plot.rs
+++ b/src/plot.rs
@@ -11,41 +11,42 @@ use ratatui::{
     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.
+/// Ring of samples, oldest first. Capacity is sized for the configured
+/// graph window at the current poll period (min 512); 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 {
+    pub fn new(capacity: usize) -> Ring {
         Ring {
-            data: vec![0.0; RING_CAP],
+            data: vec![0.0; capacity],
             start: 0,
             len: 0,
         }
     }
 
     pub fn push(&mut self, v: f64) {
-        let end = (self.start + self.len) % RING_CAP;
+        let cap = self.data.len();
+        let end = (self.start + self.len) % cap;
         self.data[end] = v;
-        if self.len < RING_CAP {
+        if self.len < cap {
             self.len += 1;
         } else {
-            self.start = (self.start + 1) % RING_CAP;
+            self.start = (self.start + 1) % cap;
         }
     }
 
-    pub fn samples(&self) -> impl Iterator<Item = f64> + '_ {
-        (0..self.len).map(move |i| self.data[(self.start + i) % RING_CAP])
+    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()])
     }
 
-    pub fn max(&self) -> f64 {
-        self.samples().fold(f64::MIN, f64::max)
+    pub fn max_window(&self, window_ticks: usize) -> f64 {
+        self.samples_window(window_ticks).fold(f64::MIN, f64::max)
     }
 }
 
@@ -57,13 +58,13 @@ fn levels(samples: &[f64], max: f64, rows: usize) -> Vec<usize> {
         .collect()
 }
 
-pub fn render(f: &mut Frame, area: Rect, ring: &Ring, max: f64, color: Color, marker_row: Option<u16>) {
+pub fn render(f: &mut Frame, area: Rect, ring: &Ring, window_ticks: usize, 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 samples: Vec<f64> = ring.samples_window(window_ticks).collect();
     let skip = samples.len().saturating_sub(cols);
     let lvls = levels(&samples[skip..], max, rows);
 
diff --git a/src/ui.rs b/src/ui.rs
index 4a943a5..2e32cdf 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -16,7 +16,6 @@ use crate::config;
 use crate::plot::{self, Ring};
 use crate::sensors;
 
-const TICK_MS: u64 = 250;
 const PEAK_WINDOW: Duration = Duration::from_secs(5 * 60);
 /// vCore shown Yellow when within this margin of the limit, Red over it.
 const VCORE_MARGIN: f64 = 0.05;
@@ -51,6 +50,61 @@ pub struct App {
     vcore_graph: Ring,
     watts_graph: Ring,
     temp_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).
+    settings_row: usize,
+    /// Edit buffers (digit keys append, backspace pops; Enter applies).
+    edit_buf: String,
+    /// Last settings-input error (inline, cleared on next keypress).
+    settings_error: Option<String>,
+}
+
+/// Settings-pane rows: (label, config field edited, unit).
+const SETTINGS_ROWS: [&str; 2] = ["Poll period", "Graph window"];
+
+fn setting_value(app: &App, row: usize) -> String {
+    match row {
+        0 => format!("{} ms", app.cfg.poll_ms),
+        _ => format!("{} s", app.cfg.graph_secs),
+    }
+}
+
+fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
+    match row {
+        0 => {
+            let v: u64 = raw
+                .parse()
+                .map_err(|_| format!("'{}' is not a number", raw))?;
+            if !(50..=10_000).contains(&v) {
+                return Err("poll_ms must be 50..10000".into());
+            }
+            app.cfg.poll_ms = v;
+        }
+        _ => {
+            let v: u64 = raw
+                .parse()
+                .map_err(|_| format!("'{}' is not a number", raw))?;
+            if !(5..=3600).contains(&v) {
+                return Err("graph_secs must be 5..3600".into());
+            }
+            app.cfg.graph_secs = v;
+ 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));
+        }
+    }
+    Ok(())
+}
+
+/// 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)
+}
+
+/// Ticks (samples) shown in a plot for the configured window.
+fn graph_ticks(cfg: &config::Config) -> usize {
+    (cfg.graph_secs * 1000 / cfg.poll_ms).max(1) as usize
 }
 
 /// Bar max = static default, or last peak + margin once the peak exceeds it.
@@ -73,6 +127,7 @@ struct CoreRow {
 impl App {
     pub fn new() -> App {
         let cfg = config::load();
+        let cap = ring_capacity(&cfg);
         App {
             cfg,
             vcore: None,
@@ -84,9 +139,13 @@ impl App {
             package_temp: None,
             cores: Vec::new(),
             last_pkg_energy: None,
-            vcore_graph: Ring::new(),
-            watts_graph: Ring::new(),
-            temp_graph: Ring::new(),
+            vcore_graph: Ring::new(cap),
+            watts_graph: Ring::new(cap),
+            temp_graph: Ring::new(cap),
+            settings_open: false,
+            settings_row: 0,
+            edit_buf: String::new(),
+            settings_error: None,
         }
     }
 
@@ -410,11 +469,12 @@ 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 RING's own window (recent history), not the
+        // 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.
+        let ticks = graph_ticks(&app.cfg);
         let ring_max = |ring: &Ring, floor: f64| -> f64 {
-            (ring.max() * (1.0 + SCALE_MARGIN)).max(floor)
+            (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);
@@ -434,7 +494,7 @@ pub fn draw(f: &mut Frame, app: &App) {
                 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);
+            plot::render(f, Rect { y: rect.y + 1, height: GRAPH_ROWS, ..rect }, ring, ticks, max, color, marker);
             x += thirds;
         }
     } else {
@@ -453,16 +513,91 @@ pub fn draw(f: &mut Frame, app: &App) {
         render_lines(f, bottom_area, &lines);
     }
 
-    // ---- footer ----
-    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
-        )),
-        Span::styled("    q: quit", Style::default().fg(Color::DarkGray)),
-    ]);
-    f.render_widget(ratatui::text::Text::from(footer), Rect { y: footer_y, height: 1, ..area });
+    // ---- footer (or settings pane overlay) ----
+    if app.settings_open {
+        draw_settings(f, area, app);
+    } else {
+        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
+            )),
+            Span::styled("    q: quit  F2: settings", Style::default().fg(Color::DarkGray)),
+        ]);
+        f.render_widget(ratatui::text::Text::from(footer), Rect { y: footer_y, height: 1, ..area });
+    }
+}
+
+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 y = area.height.saturating_sub(h + 1) / 2;
+    let rect = Rect { x: (area.width - w) / 2, y, width: w, height: h };
+
+    let mut lines = vec![Line::from(Span::styled(
+        "Settings",
+        Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
+    ))];
+    for (i, label) in SETTINGS_ROWS.iter().enumerate() {
+        let cursor = if i == app.settings_row { ">" } else { " " };
+        let editing = app.settings_row == i && !app.edit_buf.is_empty();
+        let value = if editing {
+            format!("{}_", app.edit_buf)
+        } else {
+            setting_value(app, i)
+        };
+        let style = if i == app.settings_row {
+            Style::default().add_modifier(Modifier::BOLD)
+        } else {
+            Style::default()
+        };
+        lines.push(Line::from(vec![
+            Span::styled(format!("{} {:<14}", cursor, label), Style::default().fg(Color::Cyan)),
+            Span::styled(value, style),
+        ]));
+    }
+    if let Some(err) = &app.settings_error {
+        lines.push(Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red))));
+    } else {
+        lines.push(Line::from(""));
+    }
+    lines.push(Line::from(Span::styled(
+        "↑↓ select  Enter edit/apply  F10 save  Esc close",
+        Style::default().fg(Color::DarkGray),
+    )));
+    f.render_widget(ratatui::text::Text::from(lines), rect);
+}
+
+/// Settings-pane key handling. Returns Err(msg) shown inline on bad input.
+fn settings_key(app: &mut App, key: KeyCode) -> Result<(), String> {
+    match key {
+        KeyCode::Up => {
+            app.settings_row = app.settings_row.saturating_sub(1);
+            app.edit_buf.clear();
+        }
+        KeyCode::Down => {
+            app.settings_row = (app.settings_row + 1).min(SETTINGS_ROWS.len() - 1);
+            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)?;
+        }
+        KeyCode::Backspace => {
+            app.edit_buf.pop();
+        }
+        KeyCode::Esc => {
+            app.settings_open = false;
+            app.edit_buf.clear();
+        }
+        KeyCode::Char(c) if c.is_ascii_digit() => app.edit_buf.push(c),
+        _ => {}
+    }
+    Ok(())
 }
 
 pub fn run_tui() {
@@ -471,17 +606,53 @@ pub fn run_tui() {
     loop {
         app.poll();
         terminal.draw(|f| draw(f, &app)).expect("draw failed");
-        if event::poll(Duration::from_millis(TICK_MS))
-            .unwrap_or(false)
-        {
+        let poll_ms = app.cfg.poll_ms;
+        if event::poll(Duration::from_millis(poll_ms)).unwrap_or(false) {
             if let Ok(Event::Key(key)) = event::read() {
-                if key.kind == crossterm::event::KeyEventKind::Press
-                    && matches!(key.code, KeyCode::Char('q') | KeyCode::Esc)
-                {
-                    break;
+                if key.kind != crossterm::event::KeyEventKind::Press {
+                    continue;
+                }
+                match key.code {
+                    KeyCode::F(2) => {
+                        app.settings_open = true;
+                        app.settings_error = None;
+                        app.edit_buf.clear();
+                    }
+                    KeyCode::F(10) if app.settings_open => {
+                        save_conf(&app);
+                        app.settings_open = false;
+                    }
+                    KeyCode::Char('q') | KeyCode::Esc
+                        if !app.settings_open || key.code == KeyCode::Esc =>
+                    {
+                        // Esc inside the pane closes the pane; otherwise quit.
+                        if app.settings_open {
+                            app.settings_open = false;
+                            app.edit_buf.clear();
+                        } else {
+                            break;
+                        }
+                    }
+                    code if app.settings_open => {
+                        app.settings_error = None;
+                        if let Err(e) = settings_key(&mut app, code) {
+                            app.settings_error = Some(e);
+                        }
+                    }
+                    _ => {}
                 }
             }
         }
     }
     ratatui::restore();
+}
+
+/// Write the settings block back to the conf the app loaded (F10). No-op
+/// when no file was loaded (defaults only).
+fn save_conf(app: &App) {
+    if let Some(path) = &app.cfg.loaded_path {
+        let text = std::fs::read_to_string(path).unwrap_or_default();
+        let updated = config::update_conf_text(&text, &app.cfg);
+        let _ = std::fs::write(path, updated);
+    }
 }
\ No newline at end of file