josie / alder-tools

UI v2: dual-column P/E cores, nvtop-style package plots at bottom, responsive fallbacks, layout/graphs config keys, non-CPU rails hidden by default

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

parents: 030988f

diff --git a/src/config.rs b/src/config.rs
index 6362eb5..5d136df 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -19,6 +19,17 @@ pub struct Config {
     pub temp_crit: f64,
     pub vcore_bar_max: f64,
     pub power_bar_max: f64,
+    /// Core-column arrangement: auto (2 cols if wide), single, or dual.
+    pub layout: Layout,
+    /// Show nvtop-style line graphs in the package zone.
+    pub graphs: bool,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Layout {
+    Auto,
+    Single,
+    Dual,
 }
 
 impl Default for Config {
@@ -30,31 +41,35 @@ impl Default for Config {
             temp_crit: 95.0,
             vcore_bar_max: 1.50,
             power_bar_max: 200.0,
+            layout: Layout::Auto,
+            graphs: true,
         }
     }
 }
 
-/// Default rail map: ASRock Z690M ITX/ax (nct6798). in0 CONFIRMED Vcore;
-/// differential stress-ng probing 2026-08-29: in1/in12/in14 are dead-flat
-/// fixed rails (NOT Vcore, NOT DDR5 VDD); in4/in9 respond to CPU compute;
-/// in5/6/10/11/13 float (unwired). "?" = evidence-based guess.
+/// Default rail map: ASRock Z690M ITX/ax (nct6798). in0 CONFIRMED Vcore
+/// (shown as the hero meter, not a rail row). v2 view: only CPU-relevant
+/// rails visible by default (in4 VCCIO?, in9 1.05V PROC); the others stay
+/// mapped but hidden — re-enable via `rail.inN.hide = false` in the conf.
+/// Differential stress-ng probing 2026-08-29 + BIOS cross-check; "?" =
+/// evidence-based guess.
 pub fn default_rails() -> Vec<Rail> {
     vec![
         rail("in0", "CPU Vcore", false),
-        rail("in1", "Rail 1.01", false),
-        rail("in2", "+5V", false),
-        rail("in3", "+3.3V", false),
+        rail("in1", "Fixed 1.01", true),
+        rail("in2", "Fixed 3.42", true),
+        rail("in3", "+3.3V", true),
         rail("in4", "VCCIO?", false),
         rail("in5", "", true),
         rail("in6", "", true),
-        rail("in7", "+5V", false),
-        rail("in8", "3.3V aux", false),
-        rail("in9", "CPU rail 2?", false),
+        rail("in7", "Fixed 3.42", true),
+        rail("in8", "3.3V aux", true),
+        rail("in9", "1.05V PROC", false),
         rail("in10", "", true),
         rail("in11", "", true),
-        rail("in12", "Fixed 1.14", false),
+        rail("in12", "Fixed 1.14", true),
         rail("in13", "", true),
-        rail("in14", "Fixed 1.5V", false),
+        rail("in14", "Fixed 1.5V", true),
     ]
 }
 
@@ -86,6 +101,14 @@ fn apply_line(cfg: &mut Config, line: &str) {
         "temp_crit" => cfg.temp_crit = val.parse().unwrap_or(cfg.temp_crit),
         "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",
+        "layout" => {
+            cfg.layout = match val {
+                "single" => Layout::Single,
+                "dual" => Layout::Dual,
+                _ => Layout::Auto,
+            }
+        }
         "rail.hide" => return, // needs input suffix; handled below
         _ => {}
     }
@@ -165,4 +188,24 @@ mod tests {
         apply_line(&mut cfg, "vcore_limit = notanumber");
         assert_eq!(cfg.vcore_limit, 1.403);
     }
+
+    #[test]
+    fn parses_layout_and_graphs() {
+        let mut cfg = Config::default();
+        apply_line(&mut cfg, "layout = dual");
+        apply_line(&mut cfg, "graphs = false");
+        assert_eq!(cfg.layout, Layout::Dual);
+        assert!(!cfg.graphs);
+        apply_line(&mut cfg, "layout = nonsense");
+        assert_eq!(cfg.layout, Layout::Auto);
+    }
+
+    #[test]
+    fn non_cpu_rails_hidden_by_default() {
+        let cfg = Config::default();
+        let hidden: Vec<&str> = cfg.rails.iter().filter(|r| r.hide).map(|r| r.input.as_str()).collect();
+        assert_eq!(hidden, ["in1", "in2", "in3", "in5", "in6", "in7", "in8", "in10", "in11", "in12", "in13", "in14"]);
+        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"]);
+    }
 }
diff --git a/src/main.rs b/src/main.rs
index f9eedb3..e0d9bac 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,7 @@
 //! TUI consumes the same `sensors` layer once the vCore source spike is done.
 
 mod config;
+mod plot;
 mod sensors;
 mod ui;
 
diff --git a/src/plot.rs b/src/plot.rs
new file mode 100644
index 0000000..e6072ed
--- /dev/null
+++ b/src/plot.rs
@@ -0,0 +1,110 @@
+//! nvtop-style line plot, re-implemented for ratatui (pattern studied in
+//! references/nvtop/src/plot.c — GPL, code never copied). A `Ring` is a
+//! fixed-capacity sample buffer; the rightmost drawn column is the newest.
+//! Cell glyphs: level `─`, rises/falls get corner glyphs, multi-row jumps
+//! get `│` between them.
+
+use ratatui::{
+    Frame,
+    layout::Rect,
+    style::{Color, Style},
+    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.
+pub struct Ring {
+    data: Vec<f64>,
+    start: usize,
+    len: usize,
+}
+
+const RING_CAP: usize = 512;
+
+impl Ring {
+    pub fn new() -> Ring {
+        Ring {
+            data: vec![0.0; RING_CAP],
+            start: 0,
+            len: 0,
+        }
+    }
+
+    pub fn push(&mut self, v: f64) {
+        let end = (self.start + self.len) % RING_CAP;
+        self.data[end] = v;
+        if self.len < RING_CAP {
+            self.len += 1;
+        } else {
+            self.start = (self.start + 1) % RING_CAP;
+        }
+    }
+
+    pub fn samples(&self) -> impl Iterator<Item = f64> + '_ {
+        (0..self.len).map(move |i| self.data[(self.start + i) % RING_CAP])
+    }
+}
+
+fn levels(samples: &[f64], max: f64, rows: usize) -> Vec<usize> {
+    // Row 0 = top of the plot = max value.
+    samples
+        .iter()
+        .map(|&v| (((1.0 - v / max).clamp(0.0, 1.0)) * (rows - 1) as f64).round() as usize)
+        .collect()
+}
+
+pub fn render(f: &mut Frame, area: Rect, ring: &Ring, 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 skip = samples.len().saturating_sub(cols);
+    let lvls = levels(&samples[skip..], max, rows);
+
+    let mut buf = vec![vec![(' ', Style::default()); cols]; rows];
+    let mut set = |r: usize, c: usize, ch: char, st: Style| {
+        if r < rows && c < cols && buf[r][c].0 == ' ' {
+            buf[r][c] = (ch, st);
+        }
+    };
+
+    let st = Style::default().fg(color);
+    for (c, &l) in lvls.iter().enumerate() {
+        let prev_l = if c == 0 { l } else { lvls[c - 1] };
+        if prev_l == l {
+            set(l, c, '─', st);
+        } else {
+            // Value rise = row number falls (top row is max), so
+            // prev_l > l means the line steps up on screen.
+            let (upper, lower) = if prev_l < l { (l, prev_l) } else { (prev_l, l) };
+            set(upper, c, '╮', st);
+            set(lower, c, '╰', st);
+            for r in upper + 1..lower {
+                set(r, c, '│', st);
+            }
+        }
+    }
+
+    if let Some(mr) = marker_row {
+        let mr = (mr as usize).min(rows - 1);
+        for cell in buf[mr].iter_mut().take(cols) {
+            if cell.0 == ' ' {
+                *cell = ('┄', Style::default().fg(Color::Red));
+            }
+        }
+    }
+
+    let lines: Vec<Line> = buf
+        .into_iter()
+        .map(|row| {
+            Line::from(
+                row.into_iter()
+                    .map(|(ch, sty)| Span::styled(ch.to_string(), sty))
+                    .collect::<Vec<_>>(),
+            )
+        })
+        .collect();
+    f.render_widget(ratatui::text::Text::from(lines), area);
+}
\ No newline at end of file
diff --git a/src/ui.rs b/src/ui.rs
index 7596606..21e454a 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -7,11 +7,13 @@ use std::time::{Duration, Instant};
 use crossterm::event::{self, Event, KeyCode};
 use ratatui::{
     Frame,
+    layout::Rect,
     style::{Color, Modifier, Style},
     text::{Line, Span},
 };
 
 use crate::config;
+use crate::plot::{self, Ring};
 use crate::sensors;
 
 const TICK_MS: u64 = 250;
@@ -22,6 +24,12 @@ const VCORE_MARGIN: f64 = 0.05;
 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).
+const GRAPH_ROWS: u16 = 5;
+/// Below this width the package zone falls back to meter rows (no graphs).
+const GRAPHS_MIN_WIDTH: u16 = 70;
+/// Width at/below which core sections stack to one column.
+const DUAL_COLUMN_MIN_WIDTH: u16 = 100;
 
 pub struct App {
     cfg: config::Config,
@@ -39,6 +47,10 @@ pub struct App {
     /// (that core's cpuinfo_max_freq), temperature °C.
     cores: Vec<CoreRow>,
     last_pkg_energy: Option<(u64, Instant)>,
+    /// Graph history (newest last) for the package-zone plots.
+    vcore_graph: Ring,
+    watts_graph: Ring,
+    temp_graph: Ring,
 }
 
 /// Bar max = static default, or last peak + margin once the peak exceeds it.
@@ -48,6 +60,7 @@ fn dynamic_max(peak: f64, static_max: f64, margin: f64) -> f64 {
 }
 
 /// One physical core's row in the per-core section.
+#[derive(Clone)]
 struct CoreRow {
     label: String,
     e_core: bool,
@@ -71,12 +84,16 @@ impl App {
             package_temp: None,
             cores: Vec::new(),
             last_pkg_energy: None,
+            vcore_graph: Ring::new(),
+            watts_graph: Ring::new(),
+            temp_graph: Ring::new(),
         }
     }
 
     fn poll(&mut self) {
         self.vcore = sensors::sio_vcore();
         if let Some(v) = self.vcore {
+            self.vcore_graph.push(v);
             let now = Instant::now();
             self.peak_ring.push((now, v));
             self.peak_ring
@@ -99,6 +116,9 @@ impl App {
                 .iter()
                 .find(|(l, _)| l.contains("Package"))
                 .map(|(_, v)| *v);
+            if let Some(t) = self.package_temp {
+                self.temp_graph.push(t);
+            }
 
             // Per physical core: coretemp label "Core N" N == topology
             // core_id; cpufreq is per logical cpu, so a core's speed is the
@@ -159,6 +179,7 @@ impl App {
                         if dt > 0.0 && e >= prev {
                             let w = (e - prev) as f64 / 1_000_000.0 / dt;
                             self.package_watts = Some(w);
+                            self.watts_graph.push(w);
                             if w.is_finite() {
                                 self.watts_all_time_peak =
                                     self.watts_all_time_peak.max(w);
@@ -282,148 +303,157 @@ fn fmt_opt_temp(v: Option<f64>) -> String {
     }
 }
 
+/// Build a core-column's lines: section caption + one meter per core with
+/// a blank separator after. Width adapts to the column rect.
+fn core_section_lines(title: &str, cores: &[CoreRow], app: &App, width: u16) -> Vec<Line<'static>> {
+    let mut lines = vec![Line::from(Span::styled(
+        title.to_string(),
+        Style::default().fg(Color::Cyan),
+    ))];
+    for core in cores {
+        let text = match (core.freq_khz, core.temp) {
+            (Some(f), Some(t)) => format!("{:.2}GHz  {:.0}°C", f as f64 / 1_000_000.0, t),
+            (Some(f), None) => format!("{:.2}GHz", f as f64 / 1_000_000.0),
+            (None, Some(t)) => format!("{:.0}°C", t),
+            (None, None) => "n/a".to_string(),
+        };
+        // Bar spans the cpu's own [min_freq, max_freq] window so the
+        // 800 MHz idle floor sits at 0% and turbo pegs 100%.
+        let ratio = match (core.freq_khz, core.freq_max_khz) {
+            (Some(f), max) if max > core.freq_min_khz => Some(
+                ((f as f64 - core.freq_min_khz as f64)
+                    / (max - core.freq_min_khz) as f64)
+                    .clamp(0.0, 1.0),
+            ),
+            _ => None,
+        };
+        let color = match core.temp {
+            Some(t) => temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit),
+            None => Color::DarkGray,
+        };
+        lines.push(meter_line(&core.label, &text, ratio, color, width, None));
+    }
+    lines.push(Line::from(""));
+    lines
+}
+
+/// Render one meter line inside a column rect (render_widget per line row).
+fn render_lines(f: &mut Frame, area: Rect, lines: &[Line<'static>]) {
+    for (i, line) in lines.iter().enumerate() {
+        if (i as u16) >= area.height {
+            break;
+        }
+        f.render_widget(
+            ratatui::text::Text::from(line.clone()),
+            Rect { x: area.x, y: area.y + i as u16, width: area.width, height: 1 },
+        );
+    }
+}
+
 pub fn draw(f: &mut Frame, app: &App) {
-    let w = f.area().width;
-    let mut lines: Vec<Line> = Vec::new();
+    let area = f.area();
+    let w = area.width;
+    let dual = match app.cfg.layout {
+        config::Layout::Single => false,
+        config::Layout::Dual => true,
+        config::Layout::Auto => w >= DUAL_COLUMN_MIN_WIDTH,
+    };
+
+    // ---- vertical budget: cores zone flexes, package zone fixed, footer 1
+    let footer_h = 1u16;
+    let bottom_h: u16 = if app.cfg.graphs && w >= GRAPHS_MIN_WIDTH {
+        GRAPH_ROWS + 1 // caption line above each 5-row graph
+    } 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 ----
+    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 {
+        ((w / 2).max(1), 2)
+    } else {
+        (w, 1)
+    };
+    let sections = [
+        ("P-cores", p, 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);
+    }
 
-    // ---- vCore hero ----
+    // ---- package zone (bottom) ----
+    let bottom_area = Rect { y: bottom_y, height: bottom_h, ..area };
+    let vc_max = dynamic_max(app.vcore_all_time_peak, app.cfg.vcore_bar_max, SCALE_MARGIN);
+    let watt_max = dynamic_max(app.watts_all_time_peak, app.cfg.power_bar_max, SCALE_MARGIN);
     let vc_color = match app.vcore {
         Some(v) => vcore_color(v, app.cfg.vcore_limit),
         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(),
     };
-    lines.push(Line::from(Span::styled(
-        "vCore",
-        Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
-    )));
-    // Bar maxima auto-scale: session all-time peak + margin, floored at the
-    // static defaults. OC'ing past a default compresses the bar to fit.
-    let vc_max = dynamic_max(
-        app.vcore_all_time_peak,
-        app.cfg.vcore_bar_max,
-        SCALE_MARGIN,
-    );
-    let watt_max = dynamic_max(
-        app.watts_all_time_peak,
-        app.cfg.power_bar_max,
-        SCALE_MARGIN,
-    );
-
-    lines.push(meter_line(
-        "",
-        &hero_text,
-        app.vcore.map(|v| v / vc_max),
-        vc_color,
-        w,
-        Some(app.cfg.vcore_limit / vc_max),
-    ));
-    lines.push(Line::from(""));
 
-    // ---- rails (config-mapped SIO inputs) ----
-    let rail_rows: Vec<(String, Option<f64>)> = app
-        .cfg
-        .rails
-        .iter()
-        .filter(|r| !r.hide && r.input != "in0") // in0 shown as the hero
-        .map(|r| (r.label.clone(), sensors::sio_input(&r.input)))
-        .collect();
-    if rail_rows.iter().any(|(_, v)| v.is_some()) {
-        lines.push(Line::from(Span::styled(
-            "Rails",
-            Style::default().fg(Color::Cyan),
-        )));
-        for (label, v) in &rail_rows {
-            let label = if label.is_empty() {
-                "??".to_string()
-            } else {
-                label.clone()
-            };
-            let text = match v {
-                Some(v) => format!("{:.3} V", v),
-                None => "n/a".to_string(),
-            };
-            lines.push(meter_line(&label, &text, None, Color::Magenta, w, None));
+    if app.cfg.graphs && w >= GRAPHS_MIN_WIDTH {
+        let thirds = w / 3;
+        let mut x = area.x;
+        for (i, (label, text, ring, max, color, marker)) in [
+            ("vCore", hero_text.clone(), &app.vcore_graph, vc_max, vc_color,
+                Some(((app.cfg.vcore_limit / vc_max).clamp(0.0, 1.0) * GRAPH_ROWS as f64).round() as u16)),
+            ("Pkg power", fmt_opt_watts(app.package_watts), &app.watts_graph, watt_max, Color::Blue, None),
+            ("Pkg temp", fmt_opt_temp(app.package_temp), &app.temp_graph, TEMP_BAR_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 };
+            let rect = Rect { x, y: bottom_y, width: span_w.max(1), height: bottom_h };
+            let caption = Line::from(vec![
+                Span::styled(label, Style::default().fg(Color::Cyan)),
+                Span::raw("  "),
+                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);
+            x += thirds;
         }
-        lines.push(Line::from(""));
-    }
-
-    // ---- package ----
-    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_or(Color::DarkGray, |t| {
-            temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)
-        }),
-        w,
-        None,
-    ));
-    lines.push(Line::from(""));
-
-    // ---- cores: freq bar + temp number ----
-    for (section, pred) in [("P-cores", false), ("E-cores", true)] {
-        lines.push(Line::from(Span::styled(
-            section,
-            Style::default().fg(Color::Cyan),
-        )));
-        for core in app.cores.iter().filter(|c| c.e_core == pred) {
-            let text = match (core.freq_khz, core.temp) {
-                (Some(f), Some(t)) => format!(
-                    "{:.2}GHz  {:.0}°C",
-                    f as f64 / 1_000_000.0,
-                    t
-                ),
-                (Some(f), None) => format!("{:.2}GHz", f as f64 / 1_000_000.0),
-                (None, Some(t)) => format!("{:.0}°C", t),
-                (None, None) => "n/a".to_string(),
-            };
-            // Bar spans the cpu's own [min_freq, max_freq] window so the
-            // 800 MHz idle floor sits at 0% and turbo pegs 100%.
-            let ratio = match (core.freq_khz, core.freq_max_khz) {
-                (Some(f), max) if max > core.freq_min_khz => Some(
-                    ((f as f64 - core.freq_min_khz as f64)
-                        / (max - core.freq_min_khz) as f64)
-                        .clamp(0.0, 1.0),
-                ),
-                _ => None,
-            };
-            let color = match core.temp {
-                Some(t) => temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit),
-                None => Color::DarkGray,
-            };
-        lines.push(meter_line(&core.label, &text, ratio, color, w, None));
+    } else {
+        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)),
+        ];
+        if !app.cfg.graphs || w < GRAPHS_MIN_WIDTH {
+            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_or(Color::DarkGray, |t| temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)), w, None));
+            lines.push(Line::from(""));
         }
-        lines.push(Line::from(""));
+        render_lines(f, bottom_area, &lines);
     }
 
     // ---- footer ----
-    lines.push(Line::from(vec![
+    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
+            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(lines), f.area());
+    ]);
+    f.render_widget(ratatui::text::Text::from(footer), Rect { y: footer_y, height: 1, ..area });
 }
 
 pub fn run_tui() {