From 7c3128ed8e7c16f2e61cf63fca9f6768356ed47f Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Thu, 26 Mar 2026 21:45:52 -0700 Subject: [PATCH 1/6] Overhaul TUI dashboard: accent colors, gradient sparklines, gauges, heatmap - Add MemoryUtilSource sensor (RAM/swap/cached from /proc/meminfo) - Per-panel accent colors across all 4 theme variants (12 distinct colors) - Terminal color detection (truecolor/256/basic) via COLORTERM/TERM - Gradient sparklines with per-character coloring (category-specific palettes) - PanelContent enum enabling mixed text + widget panels - CPU panel: utilization LineGauge, per-core heatmap grid, RAPL power - Memory panel: RAM/Swap usage gauges with GB labels, cached + HSMP/RAPL - CPU Freq panel: LineGauge bars showing current vs max frequency - System summary in header (hostname, CPU model, kernel version) - Headline values in panel titles (CPU %, hottest temp) - Network panel: link speed display (handles 2.5G/10G/etc) - Theme cycling hotkey (t key) - GPU sparklines: uniform color instead of per-category rainbow - Remove redundant CPU Cores panel (heatmap replaces it) --- src/output/tui/dashboard.rs | 566 ++++++++++++++++++++++-------------- src/output/tui/mod.rs | 109 ++++++- src/output/tui/theme.rs | 179 ++++++++++++ src/sensors/memory_util.rs | 197 +++++++++++++ src/sensors/mod.rs | 1 + src/sensors/poller.rs | 1 + 6 files changed, 832 insertions(+), 221 deletions(-) create mode 100644 src/sensors/memory_util.rs diff --git a/src/output/tui/dashboard.rs b/src/output/tui/dashboard.rs index c521af6..7c0a2fd 100644 --- a/src/output/tui/dashboard.rs +++ b/src/output/tui/dashboard.rs @@ -6,11 +6,11 @@ use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::{Block, Borders, LineGauge, Paragraph}; use crate::model::sensor::{SensorCategory, SensorId, SensorReading}; -use super::{SensorHistory, format_precision, sparkline_str, theme::TuiTheme}; +use super::{SensorHistory, SystemSummary, format_precision, sparkline_spans, theme::TuiTheme}; struct LayoutParams { num_columns: u8, @@ -67,7 +67,6 @@ fn panel_priority(title: &str) -> u8 { "Memory" => 2, "Voltage" => 3, "Fans" => 4, - "CPU Cores" => 4, // expendable — summary bar is in CPU panel "CPU Freq" => 4, "Power" => 5, "Storage" => 6, @@ -79,6 +78,7 @@ fn panel_priority(title: &str) -> u8 { } } +#[allow(clippy::too_many_arguments)] pub fn render( terminal: &mut Terminal>, snapshot: &[(SensorId, SensorReading)], @@ -87,11 +87,12 @@ pub fn render( sensor_count: usize, theme: &TuiTheme, dashboard_config: &crate::config::DashboardConfig, + sys: &SystemSummary, ) -> io::Result<()> { terminal.draw(|frame| { let size = frame.area(); let estimated_panels = if dashboard_config.panels.is_empty() { - 13 // built-in: 9 base + cpu_cores + cpu_freq + voltage + gpu in 3-col + 12 } else { dashboard_config.panels.len() }; @@ -107,21 +108,30 @@ pub fn render( ]) .split(size); - // Header - let header = Paragraph::new(format!( - " sio dashboard | {sensor_count} sensors | {elapsed_str}" - )) - .style(theme.accent_style()) - .block( - Block::default() - .borders(Borders::BOTTOM) - .border_style(theme.border_style()), - ); + // Header with system summary + let header_text = if sys.cpu_model.is_empty() { + format!( + " {} | {} | {sensor_count} sensors | {elapsed_str}", + sys.hostname, sys.kernel, + ) + } else { + format!( + " {} | {} | {} | {sensor_count} sensors | {elapsed_str}", + sys.hostname, sys.cpu_model, sys.kernel, + ) + }; + let header = Paragraph::new(header_text) + .style(theme.accent_style()) + .block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(theme.border_style()), + ); frame.render_widget(header, outer[0]); // Status bar let status = Paragraph::new(format!( - " q: quit | d: tree view | /: search | {sensor_count} sensors | {elapsed_str}" + " q: quit | d: tree view | t: theme | /: search | {sensor_count} sensors | {elapsed_str}" )) .style(theme.status_style()); frame.render_widget(status, outer[2]); @@ -175,13 +185,50 @@ pub fn render( struct Panel<'a> { title: String, - lines: Vec>, + /// Optional headline value shown after the title (e.g., "54.0°C"). + headline: Option, + content: PanelContent<'a>, column: Column, /// True if the panel had more data than it could show (was truncated). /// Truncated panels expand to fill remaining space; others get tight sizing. truncated: bool, } +enum PanelContent<'a> { + /// Standard text lines (current behavior for most panels). + Lines(Vec>), + /// Mixed content: text lines interleaved with gauge widgets. + Mixed(Vec>), +} + +enum PanelRow<'a> { + Text(Line<'a>), + Gauge { + label: String, + label_style: Style, + ratio: f64, + filled_style: Style, + unfilled_style: Style, + }, +} + +impl<'a> PanelContent<'a> { + fn height(&self) -> u16 { + match self { + PanelContent::Lines(lines) => lines.len() as u16, + PanelContent::Mixed(rows) => rows.len() as u16, + } + } + + #[cfg(test)] + fn lines(&self) -> &[Line<'a>] { + match self { + PanelContent::Lines(lines) => lines, + PanelContent::Mixed(_) => &[], + } + } +} + #[derive(Clone, Copy, PartialEq)] enum Column { Left, @@ -199,7 +246,7 @@ fn render_wide( let errors_height = if errors.is_empty() { 0 } else { - errors.iter().map(|p| p.lines.len() as u16 + 2).sum::() + errors.iter().map(|p| p.content.height() + 2).sum::() }; let main_split = Layout::default() @@ -246,7 +293,7 @@ fn render_three_col( let errors_height = if errors.is_empty() { 0 } else { - errors.iter().map(|p| p.lines.len() as u16 + 2).sum::() + errors.iter().map(|p| p.content.height() + 2).sum::() }; let main_split = Layout::default() @@ -317,7 +364,7 @@ fn render_column(frame: &mut ratatui::Frame, area: Rect, panels: &[&Panel<'_>], if p.truncated { Constraint::Fill(1) } else { - Constraint::Length(p.lines.len() as u16 + 2) + Constraint::Length(p.content.height() + 2) } }) .collect() @@ -327,7 +374,7 @@ fn render_column(frame: &mut ratatui::Frame, area: Rect, panels: &[&Panel<'_>], // proportionally rather than pooling at the bottom. panels .iter() - .map(|p| Constraint::Min(p.lines.len() as u16 + 2)) + .map(|p| Constraint::Min(p.content.height() + 2)) .collect() }; @@ -337,17 +384,64 @@ fn render_column(frame: &mut ratatui::Frame, area: Rect, panels: &[&Panel<'_>], .split(area); for (i, panel) in panels.iter().enumerate() { + let accent = theme.panel_accent(&panel.title); + let title_text = match &panel.headline { + Some(h) => format!(" {} {} ", panel.title, h), + None => format!(" {} ", panel.title), + }; let block = Block::default() - .title(format!(" {} ", panel.title)) - .title_style( - Style::default() - .fg(theme.label) - .add_modifier(Modifier::BOLD), - ) + .title(title_text) + .title_style(Style::default().fg(accent).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) - .border_style(theme.border_style()); - let paragraph = Paragraph::new(panel.lines.clone()).block(block); - frame.render_widget(paragraph, chunks[i]); + .border_style(Style::default().fg(accent)); + match &panel.content { + PanelContent::Lines(lines) => { + let paragraph = Paragraph::new(lines.clone()).block(block); + frame.render_widget(paragraph, chunks[i]); + } + PanelContent::Mixed(rows) => { + // Render the block border first, then render each row inside it. + let inner = block.inner(chunks[i]); + frame.render_widget(block, chunks[i]); + let row_constraints: Vec = + rows.iter().map(|_| Constraint::Length(1)).collect(); + let row_areas = Layout::default() + .direction(Direction::Vertical) + .constraints(row_constraints) + .split(inner); + for (j, row) in rows.iter().enumerate() { + if j >= row_areas.len() { + break; + } + match row { + PanelRow::Text(line) => { + let p = Paragraph::new(line.clone()); + frame.render_widget(p, row_areas[j]); + } + PanelRow::Gauge { + label, + label_style, + ratio, + filled_style, + unfilled_style, + } => { + let safe_ratio = if ratio.is_finite() { + ratio.clamp(0.0, 1.0) + } else { + 0.0 + }; + let gauge = LineGauge::default() + .ratio(safe_ratio) + .label(label.as_str()) + .style(*label_style) + .filled_style(*filled_style) + .unfilled_style(*unfilled_style); + frame.render_widget(gauge, row_areas[j]); + } + } + } + } + } } } @@ -392,9 +486,6 @@ fn build_panels<'a>( } // Per-core panels only in 3-col — too many rows for narrow layouts if three_col { - if let Some(p) = build_cpu_cores_panel(snapshot, history, spark_width, max_entries, theme) { - panels.push(p); - } if let Some(p) = build_cpu_freq_panel(snapshot, history, spark_width, max_entries, theme) { panels.push(p); } @@ -412,13 +503,13 @@ fn build_panels<'a>( // Assign columns based on layout mode if three_col { - // Left: CPU (fixed), Memory (fixed), CPU Cores (expandable, lots of data) - // Center: CPU Freq, Storage, Network, Voltage, GPU + // Left: CPU, CPU Freq + // Center: Memory, Storage, Network, Voltage, GPU // Right: Thermal, Power, Fans, Platform for panel in &mut panels { panel.column = match panel.title.as_str() { - "CPU" | "Memory" | "CPU Cores" => Column::Left, - "CPU Freq" | "Storage" | "Network" | "Voltage" | "GPU" => Column::Center, + "CPU" | "CPU Freq" => Column::Left, + "Memory" | "Storage" | "Network" | "Voltage" | "GPU" => Column::Center, "Thermal" | "Power" | "Fans" | "Platform" => Column::Right, _ => Column::Left, // Errors, etc. }; @@ -448,25 +539,27 @@ fn build_cpu_panel<'a>( return None; } - let mut lines: Vec> = Vec::new(); + let mut rows: Vec> = Vec::new(); + let mut headline: Option = None; - // Total CPU line - if let Some((id, reading)) = util_sensors.iter().find(|(id, _)| id.sensor == "total") { - let key = format!("{}/{}/{}", id.source, id.chip, id.sensor); - let spark = history - .data - .get(&key) - .map(|buf| sparkline_str(buf, spark_width)) - .unwrap_or_default(); - lines.push(Line::from(vec![ - Span::styled("Total: ", theme.label_style()), - Span::styled( - format!("{:5.1}%", reading.current), - theme.value_style(reading), - ), - Span::raw(" "), - Span::styled(spark, theme.muted_style()), - ])); + // Total CPU utilization gauge + if let Some((_, reading)) = util_sensors.iter().find(|(id, _)| id.sensor == "total") { + headline = Some(format!("{:.1}%", reading.current)); + let accent = theme.panel_cpu; + let filled_style = if reading.current > 90.0 { + Style::default().fg(theme.crit) + } else if reading.current > 70.0 { + Style::default().fg(theme.warn) + } else { + Style::default().fg(accent) + }; + rows.push(PanelRow::Gauge { + label_style: theme.label_style(), + label: format!("CPU {:.1}%", reading.current), + ratio: reading.current / 100.0, + filled_style, + unfilled_style: Style::default().fg(theme.muted), + }); } // Per-core dense bar @@ -478,23 +571,20 @@ fn build_cpu_panel<'a>( cores.sort_by(|(a, _), (b, _)| a.natural_cmp(b)); if !cores.is_empty() { - let bar: String = cores - .iter() - .map(|(_, r)| core_block_char(r.current)) - .collect(); - // Color the bar by overall utilization - let avg_util: f64 = cores.iter().map(|(_, r)| r.current).sum::() / cores.len() as f64; - let bar_color = if avg_util > 80.0 { - theme.crit - } else if avg_util > 50.0 { - theme.warn - } else { - theme.good - }; - lines.push(Line::from(vec![ - Span::styled("Cores: ", theme.label_style()), - Span::styled(bar, Style::default().fg(bar_color)), - ])); + // Per-core heatmap grid: each core is a colored █ block. + // Grid width adapts: ~24 cores per row for 3-col, more for wider panels. + let cols_per_row = 24usize; + for chunk in cores.chunks(cols_per_row) { + let spans: Vec> = chunk + .iter() + .map(|(_, r)| { + let color = + theme.sparkline_color(SensorCategory::Utilization, r.current / 100.0); + Span::styled("\u{2588}\u{2588}", Style::default().fg(color)) + }) + .collect(); + rows.push(PanelRow::Text(Line::from(spans))); + } } // RAPL package power (CPU total power draw) @@ -507,10 +597,10 @@ fn build_cpu_panel<'a>( let multi_pkg = rapl_pkgs.len() > 1; for (id, reading) in &rapl_pkgs { let key = format!("{}/{}/{}", id.source, id.chip, id.sensor); - let spark = history + let spark_spans = history .data .get(&key) - .map(|buf| sparkline_str(buf, spark_width)) + .map(|buf| sparkline_spans(buf, spark_width, reading.category, theme)) .unwrap_or_default(); let prec = format_precision(&reading.unit); // On multi-socket systems, include the package index to disambiguate @@ -519,39 +609,27 @@ fn build_cpu_panel<'a>( } else { "Power: ".into() }; - lines.push(Line::from(vec![ + let mut spans = vec![ Span::styled(label, theme.label_style()), Span::styled( format!("{:>6.*}{}", prec, reading.current, reading.unit), theme.power_style(), ), Span::raw(" "), - Span::styled(spark, theme.muted_style()), - ])); + ]; + spans.extend(spark_spans); + rows.push(PanelRow::Text(Line::from(spans))); } Some(Panel { title: "CPU".into(), - lines, + headline, + content: PanelContent::Mixed(rows), column: Column::Left, truncated: false, }) } -fn core_block_char(pct: f64) -> char { - if pct >= 87.5 { - '\u{2588}' // █ - } else if pct >= 62.5 { - '\u{2593}' // ▓ - } else if pct >= 37.5 { - '\u{2592}' // ▒ - } else if pct >= 12.5 { - '\u{2591}' // ░ - } else { - '\u{00b7}' // · - } -} - // --------------------------------------------------------------------------- // Thermal Panel // --------------------------------------------------------------------------- @@ -585,27 +663,33 @@ fn build_thermal_panel<'a>( .map(|(id, r)| { let label = truncate_label(&r.label, 20); let key = format!("{}/{}/{}", id.source, id.chip, id.sensor); - let spark = history + let spark_spans = history .data .get(&key) - .map(|buf| sparkline_str(buf, spark_width)) + .map(|buf| sparkline_spans(buf, spark_width, r.category, theme)) .unwrap_or_default(); let prec = format_precision(&r.unit); - Line::from(vec![ + let mut spans = vec![ Span::styled(format!("{label:<20} "), theme.label_style()), Span::styled( format!("{:>6.*}{}", prec, r.current, r.unit), theme.value_style(r), ), Span::raw(" "), - Span::styled(spark, theme.muted_style()), - ]) + ]; + spans.extend(spark_spans); + Line::from(spans) }) .collect(); + let headline = temps + .first() + .map(|(_, r)| format!("{:.1}\u{00b0}C", r.current)); + Some(Panel { title: "Thermal".into(), - lines, + headline, + content: PanelContent::Lines(lines), column: Column::Right, truncated: total > max_entries, }) @@ -626,13 +710,82 @@ fn build_memory_panel<'a>( snapshot: &'a [(SensorId, SensorReading)], theme: &TuiTheme, ) -> Option> { - let mut lines: Vec> = Vec::new(); + let mut rows: Vec> = Vec::new(); + + // RAM usage gauge + let ram_util = snapshot + .iter() + .find(|(id, _)| id.source == "memory" && id.sensor == "ram_util"); + let ram_used = snapshot + .iter() + .find(|(id, _)| id.source == "memory" && id.sensor == "ram_used"); + let ram_total = snapshot + .iter() + .find(|(id, _)| id.source == "memory" && id.sensor == "ram_total"); + + if let (Some((_, util)), Some((_, used)), Some((_, total))) = (ram_util, ram_used, ram_total) { + let used_gb = used.current / 1024.0; + let total_gb = total.current / 1024.0; + let label = format!( + "RAM {:.0}/{:.0} GB ({:.0}%)", + used_gb, total_gb, util.current + ); + let accent = theme.panel_memory; + rows.push(PanelRow::Gauge { + label, + label_style: theme.label_style(), + ratio: util.current / 100.0, + filled_style: Style::default().fg(accent), + unfilled_style: Style::default().fg(theme.muted), + }); + } + + // Swap usage gauge (only if swap exists) + let swap_util = snapshot + .iter() + .find(|(id, _)| id.source == "memory" && id.sensor == "swap_util"); + let swap_used = snapshot + .iter() + .find(|(id, _)| id.source == "memory" && id.sensor == "swap_used"); + let swap_total = snapshot + .iter() + .find(|(id, _)| id.source == "memory" && id.sensor == "swap_total"); + + if let (Some((_, util)), Some((_, used)), Some((_, total))) = (swap_util, swap_used, swap_total) + { + let used_gb = used.current / 1024.0; + let total_gb = total.current / 1024.0; + let label = format!( + "Swap {:.1}/{:.0} GB ({:.0}%)", + used_gb, total_gb, util.current + ); + let accent = theme.panel_memory; + rows.push(PanelRow::Gauge { + label, + label_style: theme.label_style(), + ratio: util.current / 100.0, + filled_style: Style::default().fg(accent), + unfilled_style: Style::default().fg(theme.muted), + }); + } + + // Cached + Buffers as text + if let Some((_, r)) = snapshot + .iter() + .find(|(id, _)| id.source == "memory" && id.sensor == "cached") + { + let cached_gb = r.current / 1024.0; + rows.push(PanelRow::Text(Line::from(vec![ + Span::styled("Cached + Buffers ", theme.label_style()), + Span::styled(format!("{:>7.1} GB", cached_gb), theme.info_style()), + ]))); + } // HSMP DDR bandwidth and memory clock for (_, r) in snapshot.iter().filter(|(id, _)| is_hsmp_memory_sensor(id)) { let prec = format_precision(&r.unit); let unit_str = r.unit.to_string(); - lines.push(Line::from(vec![ + rows.push(PanelRow::Text(Line::from(vec![ Span::styled( format!("{:<20} ", truncate_label(&r.label, 20)), theme.label_style(), @@ -641,7 +794,7 @@ fn build_memory_panel<'a>( format!("{:>7.*}{}", prec, r.current, unit_str), theme.info_style(), ), - ])); + ]))); } // RAPL sub-domains (core, uncore, dram — package is in the CPU panel) @@ -649,22 +802,23 @@ fn build_memory_panel<'a>( id.source == "cpu" && id.chip == "rapl" && !id.sensor.starts_with("package") }) { let prec = format_precision(&r.unit); - lines.push(Line::from(vec![ + rows.push(PanelRow::Text(Line::from(vec![ Span::styled( format!("{:<20} ", truncate_label(&r.label, 20)), theme.label_style(), ), Span::styled(format!("{:>7.*}W", prec, r.current), theme.power_style()), - ])); + ]))); } - if lines.is_empty() { + if rows.is_empty() { return None; } Some(Panel { title: "Memory".into(), - lines, + headline: None, + content: PanelContent::Mixed(rows), column: Column::Left, truncated: false, }) @@ -705,27 +859,29 @@ fn build_power_panel<'a>( .map(|(id, r)| { let label = truncate_label(&r.label, 20); let key = format!("{}/{}/{}", id.source, id.chip, id.sensor); - let spark = history + let spark_spans = history .data .get(&key) - .map(|buf| sparkline_str(buf, spark_width)) + .map(|buf| sparkline_spans(buf, spark_width, r.category, theme)) .unwrap_or_default(); let prec = format_precision(&r.unit); - Line::from(vec![ + let mut spans = vec![ Span::styled(format!("{label:<20} "), theme.label_style()), Span::styled( format!("{:>7.*}{}", prec, r.current, r.unit), theme.power_style(), ), Span::raw(" "), - Span::styled(spark, theme.muted_style()), - ]) + ]; + spans.extend(spark_spans); + Line::from(spans) }) .collect(); Some(Panel { title: "Power".into(), - lines, + headline: None, + content: PanelContent::Lines(lines), column: Column::Right, truncated: total > max_entries, }) @@ -785,7 +941,8 @@ fn build_storage_panel<'a>( Some(Panel { title: "Storage".into(), - lines, + headline: None, + content: PanelContent::Lines(lines), column: Column::Left, truncated: total_devs > max_entries, }) @@ -845,6 +1002,24 @@ fn build_network_panel<'a>( let iface = truncate_label(d.name, 10); let rx_bar = net_bar(d.rx, d.link_speed_mb, BAR_WIDTH); let tx_bar = net_bar(d.tx, d.link_speed_mb, BAR_WIDTH); + // link_speed_mb is in MiB/s; convert back to Mbps for display + let link = match d.link_speed_mb { + Some(mibs) => { + let mbps = mibs * 8.388_608; + if mbps >= 1000.0 { + let gbps = mbps / 1000.0; + // Show decimal for fractional speeds (2.5G, 5G, etc.) + if (gbps - gbps.round()).abs() < 0.1 { + format!(" {:.0}G", gbps.round()) + } else { + format!(" {:.1}G", gbps) + } + } else { + format!(" {:.0}M", mbps.round()) + } + } + None => String::new(), + }; Line::from(vec![ Span::styled(format!("{iface:<10}"), theme.label_style()), Span::styled(" \u{2193}", theme.good_style()), @@ -855,13 +1030,15 @@ fn build_network_panel<'a>( Span::styled(format!("{:>7.1}", d.tx), theme.info_style()), Span::raw(" "), Span::styled(tx_bar, theme.info_style()), + Span::styled(link, theme.muted_style()), ]) }) .collect(); Some(Panel { title: "Network".into(), - lines, + headline: None, + content: PanelContent::Lines(lines), column: Column::Right, truncated: total_ifaces > max_entries, }) @@ -902,7 +1079,8 @@ fn build_fans_panel<'a>( Some(Panel { title: "Fans".into(), - lines, + headline: None, + content: PanelContent::Lines(lines), column: Column::Left, truncated: total_fans > max_entries, }) @@ -949,78 +1127,21 @@ fn build_platform_panel<'a>( Some(Panel { title: "Platform".into(), - lines, + headline: None, + content: PanelContent::Lines(lines), column: Column::Right, truncated: total_hsmp > max_entries, }) } -// --------------------------------------------------------------------------- -// CPU Cores Panel (3-col only — per-core utilization) -// --------------------------------------------------------------------------- - -fn build_cpu_cores_panel<'a>( - snapshot: &'a [(SensorId, SensorReading)], - history: &'a SensorHistory, - spark_width: usize, - max_entries: usize, - theme: &TuiTheme, -) -> Option> { - let mut cores: Vec<&(SensorId, SensorReading)> = snapshot - .iter() - .filter(|(id, _)| { - id.source == "cpu" - && id.chip == "utilization" - && id.sensor.starts_with("cpu") - && id.sensor != "total" - }) - .collect(); - - if cores.is_empty() { - return None; - } - - cores.sort_by(|(a, _), (b, _)| a.natural_cmp(b)); - let total = cores.len(); - cores.truncate(max_entries); - - let lines: Vec> = cores - .iter() - .map(|(id, r)| { - let key = format!("{}/{}/{}", id.source, id.chip, id.sensor); - let spark = history - .data - .get(&key) - .map(|buf| sparkline_str(buf, spark_width)) - .unwrap_or_default(); - Line::from(vec![ - Span::styled( - format!("{:<20} ", truncate_label(&r.label, 20)), - theme.label_style(), - ), - Span::styled(format!("{:>5.1}%", r.current), theme.value_style(r)), - Span::raw(" "), - Span::styled(spark, theme.muted_style()), - ]) - }) - .collect(); - - Some(Panel { - title: "CPU Cores".into(), - lines, - column: Column::Left, - truncated: total > max_entries, - }) -} - // --------------------------------------------------------------------------- // CPU Freq Panel (3-col only — per-core frequency) // --------------------------------------------------------------------------- fn build_cpu_freq_panel<'a>( snapshot: &'a [(SensorId, SensorReading)], - history: &'a SensorHistory, - spark_width: usize, + _history: &'a SensorHistory, + _spark_width: usize, max_entries: usize, theme: &TuiTheme, ) -> Option> { @@ -1037,34 +1158,36 @@ fn build_cpu_freq_panel<'a>( let total = freqs.len(); freqs.truncate(max_entries); - let lines: Vec> = freqs + // Use the global max observed frequency as the gauge ceiling + let max_freq = freqs .iter() - .map(|(id, r)| { - let key = format!("{}/{}/{}", id.source, id.chip, id.sensor); - let spark = history - .data - .get(&key) - .map(|buf| sparkline_str(buf, spark_width)) - .unwrap_or_default(); - let prec = format_precision(&r.unit); - Line::from(vec![ - Span::styled( - format!("{:<20} ", truncate_label(&r.label, 20)), - theme.label_style(), - ), - Span::styled( - format!("{:>7.*}{}", prec, r.current, r.unit), - theme.value_style(r), - ), - Span::raw(" "), - Span::styled(spark, theme.muted_style()), - ]) + .map(|(_, r)| r.max) + .fold(0.0f64, f64::max) + .max(1.0); + + let rows: Vec> = freqs + .iter() + .map(|(_, r)| { + let ratio = if max_freq > 0.0 { + r.current / max_freq + } else { + 0.0 + }; + let label = truncate_label(&r.label, 14); + PanelRow::Gauge { + label: format!("{label:<14} {:>5.0}{}", r.current, r.unit), + label_style: theme.label_style(), + ratio, + filled_style: Style::default().fg(theme.panel_frequency), + unfilled_style: Style::default().fg(theme.muted), + } }) .collect(); Some(Panel { title: "CPU Freq".into(), - lines, + headline: None, + content: PanelContent::Mixed(rows), column: Column::Center, truncated: total > max_entries, }) @@ -1099,27 +1222,29 @@ fn build_voltage_panel<'a>( .map(|(id, r)| { let label = truncate_label(&r.label, 20); let key = format!("{}/{}/{}", id.source, id.chip, id.sensor); - let spark = history + let spark_spans = history .data .get(&key) - .map(|buf| sparkline_str(buf, spark_width)) + .map(|buf| sparkline_spans(buf, spark_width, r.category, theme)) .unwrap_or_default(); let prec = format_precision(&r.unit); - Line::from(vec![ + let mut spans = vec![ Span::styled(format!("{label:<20} "), theme.label_style()), Span::styled( format!("{:>7.*}{}", prec, r.current, r.unit), theme.voltage_style(), ), Span::raw(" "), - Span::styled(spark, theme.muted_style()), - ]) + ]; + spans.extend(spark_spans); + Line::from(spans) }) .collect(); Some(Panel { title: "Voltage".into(), - lines, + headline: None, + content: PanelContent::Lines(lines), column: Column::Right, // 2-col: right; 3-col: remapped to center truncated: total > max_entries, }) @@ -1165,10 +1290,11 @@ fn build_gpu_panel<'a>( }; let label = format!("GPU{gpu_idx} {sensor_name}"); let key = format!("{}/{}/{}", id.source, id.chip, id.sensor); - let spark = history + // Use uniform color for all GPU sparklines to avoid rainbow effect + let spark_spans = history .data .get(&key) - .map(|buf| sparkline_str(buf, spark_width)) + .map(|buf| sparkline_spans(buf, spark_width, SensorCategory::Other, theme)) .unwrap_or_default(); let prec = format_precision(&r.unit); let unit_str = r.unit.to_string(); @@ -1178,19 +1304,21 @@ fn build_gpu_panel<'a>( "{unit_str}{}", " ".repeat(3usize.saturating_sub(unit_display_width)) ); - Line::from(vec![ + let mut spans = vec![ Span::styled(format!("{label:<20} "), theme.label_style()), Span::styled(format!("{:>7.*}", prec, r.current), theme.value_style(r)), Span::styled(unit_padded, theme.muted_style()), Span::raw(" "), - Span::styled(spark, theme.muted_style()), - ]) + ]; + spans.extend(spark_spans); + Line::from(spans) }) .collect(); Some(Panel { title: "GPU".into(), - lines, + headline: None, + content: PanelContent::Lines(lines), column: Column::Left, // 2-col: left; 3-col: remapped to center truncated: total > max_entries, }) @@ -1236,7 +1364,8 @@ fn build_errors_panel<'a>( Some(Panel { title: "Errors".into(), - lines, + headline: None, + content: PanelContent::Lines(lines), column: Column::Left, // doesn't matter, errors span full width truncated: false, }) @@ -1360,13 +1489,13 @@ fn build_custom_panel<'a>( ]; if spark_width > 0 { let key = format!("{}/{}/{}", id.source, id.chip, id.sensor); - let spark = history + let spark_spans = history .data .get(&key) - .map(|buf| sparkline_str(buf, spark_width)) + .map(|buf| sparkline_spans(buf, spark_width, r.category, theme)) .unwrap_or_default(); spans.push(Span::raw(" ")); - spans.push(Span::styled(spark, theme.muted_style())); + spans.extend(spark_spans); } Line::from(spans) }) @@ -1374,7 +1503,8 @@ fn build_custom_panel<'a>( Some(Panel { title: config.title.clone(), - lines, + headline: None, + content: PanelContent::Lines(lines), column: Column::Left, // caller will reassign truncated: total_matched > max, }) @@ -1577,7 +1707,7 @@ mod tests { }; let panel = build_custom_panel(&snapshot, &history, &config, &layout, &theme); assert!(panel.is_some()); - assert_eq!(panel.unwrap().lines.len(), 2); // matches hwmon sensors only + assert_eq!(panel.unwrap().content.lines().len(), 2); // matches hwmon sensors only } #[test] @@ -1615,7 +1745,7 @@ mod tests { sort: None, }; let panel = build_custom_panel(&snapshot, &history, &config, &layout, &theme).unwrap(); - assert_eq!(panel.lines.len(), 1); // only the temp sensor + assert_eq!(panel.content.lines().len(), 1); // only the temp sensor } #[test] @@ -1688,9 +1818,9 @@ mod tests { sort: Some("desc".into()), }; let panel = build_custom_panel(&snapshot, &history, &config, &layout, &theme).unwrap(); - assert_eq!(panel.lines.len(), 3); + assert_eq!(panel.content.lines().len(), 3); // First line should contain "High" (80°C), not "Low" (30°C) - let first_line = format!("{}", panel.lines[0]); + let first_line = format!("{}", panel.content.lines()[0]); assert!( first_line.contains("High"), "Expected 'High' first, got: {first_line}" @@ -1750,6 +1880,6 @@ mod tests { sort: None, }; let panel = build_custom_panel(&snapshot, &history, &config, &layout, &theme).unwrap(); - assert_eq!(panel.lines.len(), 1); // clamped to 1 + assert_eq!(panel.content.lines().len(), 1); // clamped to 1 } } diff --git a/src/output/tui/mod.rs b/src/output/tui/mod.rs index 9623b42..7156841 100644 --- a/src/output/tui/mod.rs +++ b/src/output/tui/mod.rs @@ -12,6 +12,7 @@ use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::style::{Modifier, Style}; +use ratatui::text::Span; use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table}; use crate::model::sensor::{self, SensorCategory, SensorId, SensorReading, SensorUnit}; @@ -28,12 +29,51 @@ enum ViewMode { Dashboard, } +/// Static system info gathered once at TUI startup for the dashboard header. +pub(crate) struct SystemSummary { + pub hostname: String, + pub kernel: String, + pub cpu_model: String, +} + +impl SystemSummary { + fn gather() -> Self { + let hostname = std::fs::read_to_string("/etc/hostname") + .unwrap_or_default() + .trim() + .to_string(); + let kernel = std::fs::read_to_string("/proc/version") + .unwrap_or_default() + .split_whitespace() + .nth(2) + .unwrap_or("") + .to_string(); + let cpu_model = std::fs::read_to_string("/proc/cpuinfo") + .unwrap_or_default() + .lines() + .find(|l| l.starts_with("model name")) + .and_then(|l| l.split(':').nth(1)) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + Self { + hostname, + kernel, + cpu_model, + } + } +} + /// Maximum number of data points to retain per sensor for sparklines. const HISTORY_LEN: usize = 300; /// Unicode block characters for sparkline rendering, from lowest to highest. const SPARK_CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; +/// Static string slices for zero-allocation sparkline Span construction. +const SPARK_STRS: &[&str] = &[ + "\u{2581}", "\u{2582}", "\u{2583}", "\u{2584}", "\u{2585}", "\u{2586}", "\u{2587}", "\u{2588}", +]; + /// Render a `VecDeque` as a sparkline string of `width` characters. /// Values are normalized to the min/max range of the visible window. pub(crate) fn sparkline_str(data: &VecDeque, width: usize) -> String { @@ -78,6 +118,60 @@ pub(crate) fn sparkline_str(data: &VecDeque, width: usize) -> String { out } +/// Render a sparkline as a `Vec` with per-character gradient coloring. +/// Each character gets a color based on its normalized value and the sensor category. +pub(crate) fn sparkline_spans<'a>( + data: &VecDeque, + width: usize, + category: SensorCategory, + theme: &TuiTheme, +) -> Vec> { + if data.is_empty() || width == 0 { + return Vec::new(); + } + let start = data.len().saturating_sub(width); + + // First pass: find min/max over finite values in the window. + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + let mut has_finite = false; + for &v in data.iter().skip(start) { + if v.is_finite() { + has_finite = true; + if v < min { + min = v; + } + if v > max { + max = v; + } + } + } + if !has_finite { + return Vec::new(); + } + let range = max - min; + + // Second pass: build per-character spans with gradient colors. + let mut spans = Vec::with_capacity(width); + for &v in data.iter().skip(start) { + if !v.is_finite() { + continue; + } + let fraction = if range < f64::EPSILON { + 3.0 / 7.0 // flat line -> mid height ('▄') + } else { + (v - min) / range + }; + let idx = (fraction * 7.0).round() as usize; + let color = theme.sparkline_color(category, fraction); + spans.push(Span::styled( + SPARK_STRS[idx.min(7)], + Style::default().fg(color), + )); + } + spans +} + /// Per-sensor history ring buffer for sparklines. pub(crate) struct SensorHistory { pub(crate) data: HashMap>, @@ -169,6 +263,10 @@ fn run_loop( dashboard_config: &crate::config::DashboardConfig, ) -> io::Result<()> { let start = Instant::now(); + let sys_summary = SystemSummary::gather(); + let mut theme = theme.clone(); + let theme_names = ["default", "light", "high-contrast", "monochrome"]; + let mut theme_idx: usize = 0; let mut scroll_offset: usize = 0; let mut collapsed: HashSet = HashSet::new(); let mut cursor: usize = 0; @@ -230,7 +328,7 @@ fn run_loop( ViewMode::Tree => { let filter_lc = filter_query.to_ascii_lowercase(); let (display_rows, group_indices_new, collapse_key_vec_new) = - build_rows(&snapshot, &collapsed, &filter_lc, &history, theme); + build_rows(&snapshot, &collapsed, &filter_lc, &history, &theme); group_indices = group_indices_new; collapse_key_vec = collapse_key_vec_new; last_total_rows = display_rows.len(); @@ -284,7 +382,7 @@ fn run_loop( poll_warning: &poll_warning, filter_mode, filter_query: &filter_query, - theme, + theme: &theme, }; draw(terminal, display_rows, &ctx)?; } @@ -295,8 +393,9 @@ fn run_loop( &history, &elapsed_str, sensor_count, - theme, + &theme, dashboard_config, + &sys_summary, )?; } } @@ -359,6 +458,10 @@ fn run_loop( ViewMode::Dashboard => ViewMode::Tree, }; } + KeyCode::Char('t') => { + theme_idx = (theme_idx + 1) % theme_names.len(); + theme = TuiTheme::from_name(theme_names[theme_idx]); + } KeyCode::Char('/') => { // Switch to tree view if in dashboard view_mode = ViewMode::Tree; diff --git a/src/output/tui/theme.rs b/src/output/tui/theme.rs index 03408e7..9575270 100644 --- a/src/output/tui/theme.rs +++ b/src/output/tui/theme.rs @@ -2,8 +2,42 @@ use ratatui::style::{Color, Modifier, Style}; use crate::model::sensor::{SensorCategory, SensorReading}; +/// Terminal color capability level. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum ColorLevel { + None = 0, + Basic = 1, // 16 ANSI colors + Color256 = 2, // 256-color palette + TrueColor = 3, // 24-bit RGB +} + +/// Detect terminal color capability from environment variables. +pub fn detect_color_level() -> ColorLevel { + if std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) { + return ColorLevel::None; + } + if let Ok(ct) = std::env::var("COLORTERM") { + if ct == "truecolor" || ct == "24bit" { + return ColorLevel::TrueColor; + } + } + if let Ok(term) = std::env::var("TERM") { + if term == "dumb" { + return ColorLevel::None; + } + if term.ends_with("direct") { + return ColorLevel::TrueColor; + } + if term.contains("256color") { + return ColorLevel::Color256; + } + } + ColorLevel::Basic +} + #[derive(Clone, Debug)] pub struct TuiTheme { + pub color_level: ColorLevel, pub border: Color, pub muted: Color, pub cursor_bg: Color, @@ -27,11 +61,25 @@ pub struct TuiTheme { pub search_inactive_fg: Color, pub search_inactive_bg: Color, pub header_bg: Color, + // Per-panel accent colors + pub panel_cpu: Color, + pub panel_thermal: Color, + pub panel_memory: Color, + pub panel_power: Color, + pub panel_storage: Color, + pub panel_network: Color, + pub panel_fans: Color, + pub panel_gpu: Color, + pub panel_voltage: Color, + pub panel_frequency: Color, + pub panel_platform: Color, + pub panel_errors: Color, } impl Default for TuiTheme { fn default() -> Self { Self { + color_level: detect_color_level(), border: Color::Gray, muted: Color::Gray, cursor_bg: Color::Gray, @@ -55,6 +103,18 @@ impl Default for TuiTheme { search_inactive_fg: Color::Yellow, search_inactive_bg: Color::Gray, header_bg: Color::Gray, + panel_cpu: Color::Cyan, + panel_thermal: Color::LightRed, + panel_memory: Color::Blue, + panel_power: Color::Magenta, + panel_storage: Color::LightYellow, + panel_network: Color::Green, + panel_fans: Color::LightCyan, + panel_gpu: Color::LightGreen, + panel_voltage: Color::LightBlue, + panel_frequency: Color::LightMagenta, + panel_platform: Color::Yellow, + panel_errors: Color::Red, } } } @@ -62,6 +122,7 @@ impl Default for TuiTheme { impl TuiTheme { pub fn light() -> Self { Self { + color_level: detect_color_level(), border: Color::DarkGray, muted: Color::DarkGray, cursor_bg: Color::DarkGray, @@ -85,11 +146,24 @@ impl TuiTheme { search_inactive_fg: Color::Blue, search_inactive_bg: Color::DarkGray, header_bg: Color::DarkGray, + panel_cpu: Color::Blue, + panel_thermal: Color::Red, + panel_memory: Color::Magenta, + panel_power: Color::DarkGray, + panel_storage: Color::Green, + panel_network: Color::Cyan, + panel_fans: Color::Black, + panel_gpu: Color::Red, + panel_voltage: Color::Blue, + panel_frequency: Color::Cyan, + panel_platform: Color::DarkGray, + panel_errors: Color::Red, } } pub fn high_contrast() -> Self { Self { + color_level: detect_color_level(), border: Color::White, muted: Color::White, cursor_bg: Color::DarkGray, @@ -113,11 +187,24 @@ impl TuiTheme { search_inactive_fg: Color::LightYellow, search_inactive_bg: Color::DarkGray, header_bg: Color::DarkGray, + panel_cpu: Color::LightCyan, + panel_thermal: Color::LightYellow, + panel_memory: Color::LightBlue, + panel_power: Color::LightMagenta, + panel_storage: Color::LightYellow, + panel_network: Color::LightGreen, + panel_fans: Color::LightCyan, + panel_gpu: Color::LightGreen, + panel_voltage: Color::LightBlue, + panel_frequency: Color::LightCyan, + panel_platform: Color::LightMagenta, + panel_errors: Color::LightRed, } } pub fn monochrome() -> Self { Self { + color_level: ColorLevel::None, border: Color::Reset, muted: Color::Reset, cursor_bg: Color::Reset, @@ -141,6 +228,18 @@ impl TuiTheme { search_inactive_fg: Color::Reset, search_inactive_bg: Color::Reset, header_bg: Color::Reset, + panel_cpu: Color::Reset, + panel_thermal: Color::Reset, + panel_memory: Color::Reset, + panel_power: Color::Reset, + panel_storage: Color::Reset, + panel_network: Color::Reset, + panel_fans: Color::Reset, + panel_gpu: Color::Reset, + panel_voltage: Color::Reset, + panel_frequency: Color::Reset, + panel_platform: Color::Reset, + panel_errors: Color::Reset, } } @@ -257,6 +356,86 @@ impl TuiTheme { .bg(self.search_inactive_bg) } + pub fn panel_accent(&self, title: &str) -> Color { + match title { + "CPU" => self.panel_cpu, + "Thermal" => self.panel_thermal, + "Memory" => self.panel_memory, + "Power" => self.panel_power, + "Storage" => self.panel_storage, + "Network" => self.panel_network, + "Fans" => self.panel_fans, + "GPU" => self.panel_gpu, + "Voltage" => self.panel_voltage, + "CPU Freq" => self.panel_frequency, + "Platform" => self.panel_platform, + "Errors" => self.panel_errors, + _ => self.accent, + } + } + + /// Return a color for a sparkline data point based on its normalized position + /// (0.0 = min in window, 1.0 = max) and the sensor category. + /// On truecolor terminals, returns smooth RGB gradients. + /// On basic terminals, falls back to 3-step ANSI colors. + pub fn sparkline_color(&self, category: SensorCategory, fraction: f64) -> Color { + let t = fraction.clamp(0.0, 1.0); + + if self.color_level == ColorLevel::None { + return Color::Reset; + } + + if self.color_level >= ColorLevel::TrueColor { + return match category { + // Dim red-orange → bright red-orange + SensorCategory::Temperature => Color::Rgb( + (120.0 + t * 135.0) as u8, + (40.0 + t * 50.0) as u8, + (20.0 + t * 20.0) as u8, + ), + // Near-black → bright cyan (idle cores are dark) + SensorCategory::Utilization => Color::Rgb( + (5.0 + t * 85.0) as u8, + (15.0 + t * 240.0) as u8, + (20.0 + t * 235.0) as u8, + ), + // Dim magenta → bright magenta + SensorCategory::Power | SensorCategory::Current => Color::Rgb( + (100.0 + t * 155.0) as u8, + (40.0 + t * 40.0) as u8, + (120.0 + t * 135.0) as u8, + ), + // Dim blue → bright blue + SensorCategory::Voltage => Color::Rgb( + (60.0 + t * 80.0) as u8, + (80.0 + t * 100.0) as u8, + (160.0 + t * 95.0) as u8, + ), + // Dim cyan → bright cyan + SensorCategory::Frequency => Color::Rgb( + (40.0 + t * 80.0) as u8, + (140.0 + t * 115.0) as u8, + (160.0 + t * 95.0) as u8, + ), + // Dim gray → bright white + _ => { + let v = (100.0 + t * 155.0) as u8; + Color::Rgb(v, v, v) + } + }; + } + + // Basic/256 fallback: single color per category + match category { + SensorCategory::Temperature => self.panel_thermal, + SensorCategory::Utilization => self.panel_cpu, + SensorCategory::Power | SensorCategory::Current => self.power, + SensorCategory::Voltage => self.voltage, + SensorCategory::Frequency => self.info, + _ => self.muted, + } + } + pub fn value_style(&self, reading: &SensorReading) -> Style { match reading.category { SensorCategory::Temperature => { diff --git a/src/sensors/memory_util.rs b/src/sensors/memory_util.rs new file mode 100644 index 0000000..6f10fb2 --- /dev/null +++ b/src/sensors/memory_util.rs @@ -0,0 +1,197 @@ +use crate::model::sensor::{SensorCategory, SensorId, SensorReading, SensorUnit}; +use std::fs; + +pub struct MemoryUtilSource; + +impl MemoryUtilSource { + pub fn discover() -> Self { + Self + } + + pub fn poll(&mut self) -> Vec<(SensorId, SensorReading)> { + let Some(info) = parse_meminfo() else { + return Vec::new(); + }; + + let mut readings = Vec::new(); + + // RAM used = total - available (matches htop/free behavior) + let ram_used_mb = (info.total_kb.saturating_sub(info.available_kb)) / 1024; + let ram_total_mb = info.total_kb / 1024; + let ram_util = if info.total_kb > 0 { + 100.0 * (1.0 - (info.available_kb as f64 / info.total_kb as f64)) + } else { + 0.0 + }; + + readings.push(reading( + "ram_used", + "RAM Used", + ram_used_mb as f64, + SensorUnit::Megabytes, + SensorCategory::Memory, + )); + readings.push(reading( + "ram_total", + "RAM Total", + ram_total_mb as f64, + SensorUnit::Megabytes, + SensorCategory::Memory, + )); + readings.push(reading( + "ram_util", + "RAM Utilization", + ram_util, + SensorUnit::Percent, + SensorCategory::Utilization, + )); + + // Swap (only if swap is configured) + if info.swap_total_kb > 0 { + let swap_used_kb = info.swap_total_kb.saturating_sub(info.swap_free_kb); + let swap_used_mb = swap_used_kb / 1024; + let swap_total_mb = info.swap_total_kb / 1024; + let swap_util = 100.0 * (swap_used_kb as f64 / info.swap_total_kb as f64); + + readings.push(reading( + "swap_used", + "Swap Used", + swap_used_mb as f64, + SensorUnit::Megabytes, + SensorCategory::Memory, + )); + readings.push(reading( + "swap_total", + "Swap Total", + swap_total_mb as f64, + SensorUnit::Megabytes, + SensorCategory::Memory, + )); + readings.push(reading( + "swap_util", + "Swap Utilization", + swap_util, + SensorUnit::Percent, + SensorCategory::Utilization, + )); + } + + // Cached + Buffers (useful for understanding "reclaimable" memory) + let cached_mb = (info.cached_kb + info.buffers_kb) / 1024; + readings.push(reading( + "cached", + "Cached + Buffers", + cached_mb as f64, + SensorUnit::Megabytes, + SensorCategory::Memory, + )); + + readings + } +} + +impl crate::sensors::SensorSource for MemoryUtilSource { + fn name(&self) -> &str { + "memory_util" + } + + fn poll(&mut self) -> Vec<(SensorId, SensorReading)> { + MemoryUtilSource::poll(self) + } +} + +fn reading( + sensor: &str, + label: &str, + value: f64, + unit: SensorUnit, + category: SensorCategory, +) -> (SensorId, SensorReading) { + let id = SensorId { + source: "memory".into(), + chip: "system".into(), + sensor: sensor.to_string(), + }; + ( + id, + SensorReading::new(label.to_string(), value, unit, category), + ) +} + +struct MemInfo { + total_kb: u64, + available_kb: u64, + cached_kb: u64, + buffers_kb: u64, + swap_total_kb: u64, + swap_free_kb: u64, +} + +fn parse_meminfo() -> Option { + let content = fs::read_to_string("/proc/meminfo").ok()?; + + let mut total = 0u64; + let mut available = 0u64; + let mut cached = 0u64; + let mut buffers = 0u64; + let mut swap_total = 0u64; + let mut swap_free = 0u64; + + for line in content.lines() { + let mut parts = line.split_whitespace(); + let Some(key) = parts.next() else { continue }; + let val: u64 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0); + + match key { + "MemTotal:" => total = val, + "MemAvailable:" => available = val, + "Cached:" => cached = val, + "Buffers:" => buffers = val, + "SwapTotal:" => swap_total = val, + "SwapFree:" => swap_free = val, + _ => {} + } + } + + Some(MemInfo { + total_kb: total, + available_kb: available, + cached_kb: cached, + buffers_kb: buffers, + swap_total_kb: swap_total, + swap_free_kb: swap_free, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_poll_returns_readings() { + let mut src = MemoryUtilSource::discover(); + let readings = src.poll(); + // On any Linux system, we should get at least RAM readings + assert!( + readings.len() >= 4, + "expected at least 4 readings, got {}", + readings.len() + ); + + // Check RAM util is in valid range + let util = readings.iter().find(|(id, _)| id.sensor == "ram_util"); + assert!(util.is_some(), "ram_util reading missing"); + let (_, reading) = util.unwrap(); + assert!(reading.current >= 0.0 && reading.current <= 100.0); + } + + #[test] + fn test_sensor_ids_use_memory_source() { + let mut src = MemoryUtilSource::discover(); + let readings = src.poll(); + for (id, _) in &readings { + assert_eq!(id.source, "memory"); + assert_eq!(id.chip, "system"); + } + } +} diff --git a/src/sensors/mod.rs b/src/sensors/mod.rs index 3161338..9abad06 100644 --- a/src/sensors/mod.rs +++ b/src/sensors/mod.rs @@ -10,6 +10,7 @@ pub mod hwmon; pub mod i2c; pub mod ipmi; pub mod mce; +pub mod memory_util; pub mod network_stats; pub mod poller; pub mod rapl; diff --git a/src/sensors/poller.rs b/src/sensors/poller.rs index 0149283..ca68767 100644 --- a/src/sensors/poller.rs +++ b/src/sensors/poller.rs @@ -279,6 +279,7 @@ fn discover_all_sources( Box::new(super::edac::EdacSource::discover()), Box::new(super::aer::AerSource::discover()), Box::new(super::mce::MceSource::discover()), + Box::new(super::memory_util::MemoryUtilSource::discover()), ]; // Tegra platform sources (devfreq GPU, hardware engines) From 1796a0066fba6ba39e9300275d141e2024cb6bf2 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 27 Mar 2026 10:40:16 -0700 Subject: [PATCH 2/6] Address review feedback: fix doc comment, theme cycling start position - Fix sparkline_color doc comment (said "3-step" but impl is single color) - Add name field to TuiTheme so theme cycling starts from the active theme --- src/output/tui/mod.rs | 6 +++++- src/output/tui/theme.rs | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/output/tui/mod.rs b/src/output/tui/mod.rs index 7156841..0b50f97 100644 --- a/src/output/tui/mod.rs +++ b/src/output/tui/mod.rs @@ -266,7 +266,11 @@ fn run_loop( let sys_summary = SystemSummary::gather(); let mut theme = theme.clone(); let theme_names = ["default", "light", "high-contrast", "monochrome"]; - let mut theme_idx: usize = 0; + // Start cycling from the current theme's position + let mut theme_idx: usize = theme_names + .iter() + .position(|&n| n == theme.name) + .unwrap_or(0); let mut scroll_offset: usize = 0; let mut collapsed: HashSet = HashSet::new(); let mut cursor: usize = 0; diff --git a/src/output/tui/theme.rs b/src/output/tui/theme.rs index 9575270..6720f43 100644 --- a/src/output/tui/theme.rs +++ b/src/output/tui/theme.rs @@ -37,6 +37,7 @@ pub fn detect_color_level() -> ColorLevel { #[derive(Clone, Debug)] pub struct TuiTheme { + pub name: String, pub color_level: ColorLevel, pub border: Color, pub muted: Color, @@ -79,6 +80,7 @@ pub struct TuiTheme { impl Default for TuiTheme { fn default() -> Self { Self { + name: "default".into(), color_level: detect_color_level(), border: Color::Gray, muted: Color::Gray, @@ -122,6 +124,7 @@ impl Default for TuiTheme { impl TuiTheme { pub fn light() -> Self { Self { + name: "light".into(), color_level: detect_color_level(), border: Color::DarkGray, muted: Color::DarkGray, @@ -163,6 +166,7 @@ impl TuiTheme { pub fn high_contrast() -> Self { Self { + name: "high-contrast".into(), color_level: detect_color_level(), border: Color::White, muted: Color::White, @@ -204,6 +208,7 @@ impl TuiTheme { pub fn monochrome() -> Self { Self { + name: "monochrome".into(), color_level: ColorLevel::None, border: Color::Reset, muted: Color::Reset, @@ -377,7 +382,7 @@ impl TuiTheme { /// Return a color for a sparkline data point based on its normalized position /// (0.0 = min in window, 1.0 = max) and the sensor category. /// On truecolor terminals, returns smooth RGB gradients. - /// On basic terminals, falls back to 3-step ANSI colors. + /// On basic terminals, falls back to a single ANSI color per category. pub fn sparkline_color(&self, category: SensorCategory, fraction: f64) -> Color { let t = fraction.clamp(0.0, 1.0); From 2e64df0ba4fc0d9156421f84e88ae0d79fa39e31 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 27 Mar 2026 10:59:40 -0700 Subject: [PATCH 3/6] Polish dashboard: multi-col panels, theme cycling, heatmap tuning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Multi-column layout (MultiCol variant) for CPU Freq and Fans panels - Theme cycling hotkey (t) with correct start position via TuiTheme.name - CPU heatmap floor darkened to near-black for idle cores - Sparkline gradients: red-orange for thermal, near-black→cyan for utilization - GPU sparklines unified to single color (no per-category rainbow) - RAM/Swap gauge labels match panel text color - Network link speed: correct MiB/s→Mbps conversion, fractional speed support - System summary header (hostname, CPU model, kernel) - Headline values in panel titles (CPU %, hottest temp) - Memory→center column, CPU Freq→left column in 3-col layout - Remove duplicate CPU label from gauge (headline covers it) - Fix CPU Freq truncation flag for 2-column layout - Fix sparkline_color doc comment accuracy --- src/output/tui/dashboard.rs | 128 ++++++++++++++++++++++-------------- 1 file changed, 80 insertions(+), 48 deletions(-) diff --git a/src/output/tui/dashboard.rs b/src/output/tui/dashboard.rs index 7c0a2fd..3a23f42 100644 --- a/src/output/tui/dashboard.rs +++ b/src/output/tui/dashboard.rs @@ -199,6 +199,11 @@ enum PanelContent<'a> { Lines(Vec>), /// Mixed content: text lines interleaved with gauge widgets. Mixed(Vec>), + /// Multi-column layout: rows distributed across N columns. + MultiCol { + rows: Vec>, + columns: u8, + }, } enum PanelRow<'a> { @@ -217,6 +222,10 @@ impl<'a> PanelContent<'a> { match self { PanelContent::Lines(lines) => lines.len() as u16, PanelContent::Mixed(rows) => rows.len() as u16, + PanelContent::MultiCol { rows, columns } => { + let cols = (*columns).max(1) as usize; + rows.len().div_ceil(cols) as u16 + } } } @@ -224,7 +233,7 @@ impl<'a> PanelContent<'a> { fn lines(&self) -> &[Line<'a>] { match self { PanelContent::Lines(lines) => lines, - PanelContent::Mixed(_) => &[], + PanelContent::Mixed(_) | PanelContent::MultiCol { .. } => &[], } } } @@ -400,44 +409,27 @@ fn render_column(frame: &mut ratatui::Frame, area: Rect, panels: &[&Panel<'_>], frame.render_widget(paragraph, chunks[i]); } PanelContent::Mixed(rows) => { - // Render the block border first, then render each row inside it. let inner = block.inner(chunks[i]); frame.render_widget(block, chunks[i]); - let row_constraints: Vec = - rows.iter().map(|_| Constraint::Length(1)).collect(); - let row_areas = Layout::default() - .direction(Direction::Vertical) - .constraints(row_constraints) + render_rows(frame, inner, rows); + } + PanelContent::MultiCol { rows, columns } => { + let inner = block.inner(chunks[i]); + frame.render_widget(block, chunks[i]); + let ncols = (*columns).max(1) as usize; + let rows_per_col = rows.len().div_ceil(ncols); + let col_constraints: Vec = (0..ncols) + .map(|_| Constraint::Ratio(1, ncols as u32)) + .collect(); + let col_areas = Layout::default() + .direction(Direction::Horizontal) + .constraints(col_constraints) .split(inner); - for (j, row) in rows.iter().enumerate() { - if j >= row_areas.len() { - break; - } - match row { - PanelRow::Text(line) => { - let p = Paragraph::new(line.clone()); - frame.render_widget(p, row_areas[j]); - } - PanelRow::Gauge { - label, - label_style, - ratio, - filled_style, - unfilled_style, - } => { - let safe_ratio = if ratio.is_finite() { - ratio.clamp(0.0, 1.0) - } else { - 0.0 - }; - let gauge = LineGauge::default() - .ratio(safe_ratio) - .label(label.as_str()) - .style(*label_style) - .filled_style(*filled_style) - .unfilled_style(*unfilled_style); - frame.render_widget(gauge, row_areas[j]); - } + for (c, col_area) in col_areas.iter().enumerate() { + let start = c * rows_per_col; + let end = rows.len().min(start + rows_per_col); + if start < end { + render_rows(frame, *col_area, &rows[start..end]); } } } @@ -445,6 +437,45 @@ fn render_column(frame: &mut ratatui::Frame, area: Rect, panels: &[&Panel<'_>], } } +fn render_rows(frame: &mut ratatui::Frame, area: Rect, rows: &[PanelRow<'_>]) { + let row_constraints: Vec = rows.iter().map(|_| Constraint::Length(1)).collect(); + let row_areas = Layout::default() + .direction(Direction::Vertical) + .constraints(row_constraints) + .split(area); + for (j, row) in rows.iter().enumerate() { + if j >= row_areas.len() { + break; + } + match row { + PanelRow::Text(line) => { + let p = Paragraph::new(line.clone()); + frame.render_widget(p, row_areas[j]); + } + PanelRow::Gauge { + label, + label_style, + ratio, + filled_style, + unfilled_style, + } => { + let safe_ratio = if ratio.is_finite() { + ratio.clamp(0.0, 1.0) + } else { + 0.0 + }; + let gauge = LineGauge::default() + .ratio(safe_ratio) + .label(label.as_str()) + .style(*label_style) + .filled_style(*filled_style) + .unfilled_style(*unfilled_style); + frame.render_widget(gauge, row_areas[j]); + } + } + } +} + fn build_panels<'a>( snapshot: &'a [(SensorId, SensorReading)], history: &'a SensorHistory, @@ -555,7 +586,7 @@ fn build_cpu_panel<'a>( }; rows.push(PanelRow::Gauge { label_style: theme.label_style(), - label: format!("CPU {:.1}%", reading.current), + label: String::new(), ratio: reading.current / 100.0, filled_style, unfilled_style: Style::default().fg(theme.muted), @@ -1064,25 +1095,25 @@ fn build_fans_panel<'a>( fans.sort_by(|(a, _), (b, _)| a.natural_cmp(b)); let total_fans = fans.len(); - fans.truncate(max_entries); + fans.truncate(max_entries * 2); - let lines: Vec> = fans + let rows: Vec> = fans .iter() .map(|(_, r)| { - let label = truncate_label(&r.label, 20); - Line::from(vec![ - Span::styled(format!("{label:<20} "), theme.label_style()), + let label = truncate_label(&r.label, 16); + PanelRow::Text(Line::from(vec![ + Span::styled(format!("{label:<16} "), theme.label_style()), Span::styled(format!("{:>5.0} RPM", r.current), theme.value_style(r)), - ]) + ])) }) .collect(); Some(Panel { title: "Fans".into(), headline: None, - content: PanelContent::Lines(lines), + content: PanelContent::MultiCol { rows, columns: 2 }, column: Column::Left, - truncated: total_fans > max_entries, + truncated: total_fans > max_entries * 2, }) } @@ -1156,7 +1187,8 @@ fn build_cpu_freq_panel<'a>( freqs.sort_by(|(a, _), (b, _)| a.natural_cmp(b)); let total = freqs.len(); - freqs.truncate(max_entries); + // 2-column layout: allow twice as many entries since each column holds half + freqs.truncate(max_entries * 2); // Use the global max observed frequency as the gauge ceiling let max_freq = freqs @@ -1187,9 +1219,9 @@ fn build_cpu_freq_panel<'a>( Some(Panel { title: "CPU Freq".into(), headline: None, - content: PanelContent::Mixed(rows), + content: PanelContent::MultiCol { rows, columns: 2 }, column: Column::Center, - truncated: total > max_entries, + truncated: total > max_entries * 2, }) } From 6b8e612d6681320782bc57d975c6d08a1c10782d Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 27 Mar 2026 14:31:51 -0700 Subject: [PATCH 4/6] Adaptive layout: tight panels, conditional multi-column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only use 2-column layout for CPU Freq when entries exceed single-column capacity; Fans go 2-column when more than 6 entries - Truncated panels expand into space freed by compact panels, so panels with more data (e.g., many thermal sensors) grow naturally - Simplified constraint logic: always use Length for non-truncated panels, Fill for truncated — no separate branch needed --- src/output/tui/dashboard.rs | 78 +++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 33 deletions(-) diff --git a/src/output/tui/dashboard.rs b/src/output/tui/dashboard.rs index 3a23f42..c0de9de 100644 --- a/src/output/tui/dashboard.rs +++ b/src/output/tui/dashboard.rs @@ -1,16 +1,16 @@ use std::collections::HashMap; use std::io::{self, Stdout}; -use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, LineGauge, Paragraph}; +use ratatui::Terminal; use crate::model::sensor::{SensorCategory, SensorId, SensorReading}; -use super::{SensorHistory, SystemSummary, format_precision, sparkline_spans, theme::TuiTheme}; +use super::{format_precision, sparkline_spans, theme::TuiTheme, SensorHistory, SystemSummary}; struct LayoutParams { num_columns: u8, @@ -362,30 +362,18 @@ fn render_column(frame: &mut ratatui::Frame, area: Rect, panels: &[&Panel<'_>], } // Truncated panels (have more data to show) expand to fill remaining space. - // Non-truncated panels (showing all their data) get tight sizing. - // If nothing is truncated, spread remaining space evenly across all panels - // so no single gap pools at the bottom. - let has_truncated = panels.iter().any(|p| p.truncated); - let constraints: Vec = if has_truncated { - panels - .iter() - .map(|p| { - if p.truncated { - Constraint::Fill(1) - } else { - Constraint::Length(p.content.height() + 2) - } - }) - .collect() - } else { - // No truncation — all data is visible. Use Min(content) so each panel - // gets at least its content height, then remaining space distributes - // proportionally rather than pooling at the bottom. - panels - .iter() - .map(|p| Constraint::Min(p.content.height() + 2)) - .collect() - }; + // Non-truncated panels get tight sizing. This ensures panels with more + // data (e.g., many thermal sensors) grow into space freed by smaller panels. + let constraints: Vec = panels + .iter() + .map(|p| { + if p.truncated { + Constraint::Fill(1) + } else { + Constraint::Length(p.content.height() + 2) + } + }) + .collect(); let chunks = Layout::default() .direction(Direction::Vertical) @@ -1095,7 +1083,13 @@ fn build_fans_panel<'a>( fans.sort_by(|(a, _), (b, _)| a.natural_cmp(b)); let total_fans = fans.len(); - fans.truncate(max_entries * 2); + let use_two_col = total_fans > 6; + let effective_max = if use_two_col { + max_entries * 2 + } else { + max_entries + }; + fans.truncate(effective_max); let rows: Vec> = fans .iter() @@ -1108,12 +1102,18 @@ fn build_fans_panel<'a>( }) .collect(); + let content = if use_two_col { + PanelContent::MultiCol { rows, columns: 2 } + } else { + PanelContent::Mixed(rows) + }; + Some(Panel { title: "Fans".into(), headline: None, - content: PanelContent::MultiCol { rows, columns: 2 }, + content, column: Column::Left, - truncated: total_fans > max_entries * 2, + truncated: total_fans > effective_max, }) } @@ -1187,8 +1187,14 @@ fn build_cpu_freq_panel<'a>( freqs.sort_by(|(a, _), (b, _)| a.natural_cmp(b)); let total = freqs.len(); - // 2-column layout: allow twice as many entries since each column holds half - freqs.truncate(max_entries * 2); + // Only use 2-column layout when entries exceed single-column capacity + let use_two_col = total > max_entries; + let effective_max = if use_two_col { + max_entries * 2 + } else { + max_entries + }; + freqs.truncate(effective_max); // Use the global max observed frequency as the gauge ceiling let max_freq = freqs @@ -1216,12 +1222,18 @@ fn build_cpu_freq_panel<'a>( }) .collect(); + let content = if use_two_col { + PanelContent::MultiCol { rows, columns: 2 } + } else { + PanelContent::Mixed(rows) + }; + Some(Panel { title: "CPU Freq".into(), headline: None, - content: PanelContent::MultiCol { rows, columns: 2 }, + content, column: Column::Center, - truncated: total > max_entries * 2, + truncated: total > effective_max, }) } From 6a133d991b99a7134cb57a6206046c88e986c5d5 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 27 Mar 2026 14:34:25 -0700 Subject: [PATCH 5/6] Fix formatting, add credential patterns to .gitignore --- .gitignore | 5 +++++ src/output/tui/dashboard.rs | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 748d57d..001add1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ progress.md re_data/ +# Credential files +*.pass +*.kdbx* +*-ssh+gpg-keys* + # Kernel module build artifacts kmod/*/*.o kmod/*/*.ko diff --git a/src/output/tui/dashboard.rs b/src/output/tui/dashboard.rs index c0de9de..4c40c86 100644 --- a/src/output/tui/dashboard.rs +++ b/src/output/tui/dashboard.rs @@ -1,16 +1,16 @@ use std::collections::HashMap; use std::io::{self, Stdout}; +use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, LineGauge, Paragraph}; -use ratatui::Terminal; use crate::model::sensor::{SensorCategory, SensorId, SensorReading}; -use super::{format_precision, sparkline_spans, theme::TuiTheme, SensorHistory, SystemSummary}; +use super::{SensorHistory, SystemSummary, format_precision, sparkline_spans, theme::TuiTheme}; struct LayoutParams { num_columns: u8, From 5ed80c69e7817f334c7b0379b088f17a9556196d Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Fri, 27 Mar 2026 14:45:48 -0700 Subject: [PATCH 6/6] Address review: clamp ram_util, guard theme cycling, fix comments - Clamp ram_util to 0..100% to handle available > total edge case - Return None from parse_meminfo when total_kb is 0 - Disable theme cycling (t key) when --color never is active - Fix heatmap comment (cols_per_row is fixed, not adaptive) --- src/output/tui/dashboard.rs | 4 ++-- src/output/tui/mod.rs | 5 +++-- src/sensors/memory_util.rs | 6 +++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/output/tui/dashboard.rs b/src/output/tui/dashboard.rs index 4c40c86..4c88a8d 100644 --- a/src/output/tui/dashboard.rs +++ b/src/output/tui/dashboard.rs @@ -590,8 +590,8 @@ fn build_cpu_panel<'a>( cores.sort_by(|(a, _), (b, _)| a.natural_cmp(b)); if !cores.is_empty() { - // Per-core heatmap grid: each core is a colored █ block. - // Grid width adapts: ~24 cores per row for 3-col, more for wider panels. + // Per-core heatmap grid: each core is a colored ██ block. + // Fixed at 24 cores per row (fits 3-col layout with 2-char-wide blocks). let cols_per_row = 24usize; for chunk in cores.chunks(cols_per_row) { let spans: Vec> = chunk diff --git a/src/output/tui/mod.rs b/src/output/tui/mod.rs index 0b50f97..1bba988 100644 --- a/src/output/tui/mod.rs +++ b/src/output/tui/mod.rs @@ -21,7 +21,7 @@ use crate::sensors::poller::PollStatsState; mod dashboard; pub mod theme; -use theme::TuiTheme; +use theme::{ColorLevel, TuiTheme}; #[derive(Clone, Copy, PartialEq)] enum ViewMode { @@ -265,6 +265,7 @@ fn run_loop( let start = Instant::now(); let sys_summary = SystemSummary::gather(); let mut theme = theme.clone(); + let colors_locked = theme.name == "monochrome" && theme.color_level == ColorLevel::None; let theme_names = ["default", "light", "high-contrast", "monochrome"]; // Start cycling from the current theme's position let mut theme_idx: usize = theme_names @@ -462,7 +463,7 @@ fn run_loop( ViewMode::Dashboard => ViewMode::Tree, }; } - KeyCode::Char('t') => { + KeyCode::Char('t') if !colors_locked => { theme_idx = (theme_idx + 1) % theme_names.len(); theme = TuiTheme::from_name(theme_names[theme_idx]); } diff --git a/src/sensors/memory_util.rs b/src/sensors/memory_util.rs index 6f10fb2..f9a228e 100644 --- a/src/sensors/memory_util.rs +++ b/src/sensors/memory_util.rs @@ -19,7 +19,7 @@ impl MemoryUtilSource { let ram_used_mb = (info.total_kb.saturating_sub(info.available_kb)) / 1024; let ram_total_mb = info.total_kb / 1024; let ram_util = if info.total_kb > 0 { - 100.0 * (1.0 - (info.available_kb as f64 / info.total_kb as f64)) + (100.0 * (1.0 - (info.available_kb as f64 / info.total_kb as f64))).clamp(0.0, 100.0) } else { 0.0 }; @@ -153,6 +153,10 @@ fn parse_meminfo() -> Option { } } + if total == 0 { + return None; + } + Some(MemInfo { total_kb: total, available_kb: available,