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