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)
}
_ => {}
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