use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use super::theme::Theme;
use crate::app::AppState;
use muxtop_core::system::{CoreSnapshot, SystemSnapshot};
pub fn draw_general_tab(frame: &mut Frame, area: Rect, app: &AppState, theme: &Theme) {
let Some(ref snapshot) = app.last_snapshot else {
let para = Paragraph::new("Waiting for data...").alignment(Alignment::Center);
frame.render_widget(para, area);
return;
};
let unicode = app.term_caps.unicode;
let n_cores = snapshot.cpu.cores.len();
let cpu_h = if n_cores == 0 {
3
} else if n_cores <= 16 {
n_cores as u16 + 2 } else {
(n_cores.div_ceil(2)) as u16 + 2 };
let has_swap = snapshot.memory.swap_total > 0;
let mem_h = if has_swap { 4 } else { 3 };
let chunks = Layout::vertical([
Constraint::Length(cpu_h),
Constraint::Length(mem_h),
Constraint::Length(1),
Constraint::Min(0), ])
.split(area);
draw_cpu_bars(frame, chunks[0], snapshot, unicode, theme);
draw_memory_bars(frame, chunks[1], snapshot, unicode, theme);
draw_system_info(frame, chunks[2], snapshot, theme);
}
fn format_uptime(secs: u64) -> String {
let days = secs / 86400;
let hours = (secs % 86400) / 3600;
let mins = (secs % 3600) / 60;
format!("{days}d {hours}h {mins}m")
}
fn draw_system_info(frame: &mut Frame, area: Rect, snapshot: &SystemSnapshot, theme: &Theme) {
let running = snapshot
.processes
.iter()
.filter(|p| p.status == "Running")
.count();
let total = snapshot.processes.len();
let uptime = format_uptime(snapshot.load.uptime_secs);
let line = Line::from(vec![
Span::styled(
format!(" Uptime: {uptime} "),
Style::default()
.fg(theme.fg)
.bg(theme.selection_bg)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!(
" Load: {:.2} {:.2} {:.2} ",
snapshot.load.one, snapshot.load.five, snapshot.load.fifteen
),
Style::default().fg(theme.fg).bg(theme.selection_bg),
),
Span::raw(" "),
Span::styled(
format!(" Tasks: {total} ({running} running) "),
Style::default().fg(theme.fg).bg(theme.selection_bg),
),
]);
let para = Paragraph::new(line).style(Style::default().bg(theme.bg));
frame.render_widget(para, area);
}
fn format_bytes_gb(bytes: u64) -> String {
let gib = bytes as f64 / (1024.0 * 1024.0 * 1024.0);
format!("{gib:.1}")
}
fn draw_memory_bars(
frame: &mut Frame,
area: Rect,
snapshot: &SystemSnapshot,
unicode: bool,
theme: &Theme,
) {
let block = Block::default()
.title(" Memory ")
.title_style(
Style::default()
.fg(theme.accent_primary)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(theme.text_dim));
let inner = block.inner(area);
frame.render_widget(block, area);
let mem = &snapshot.memory;
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(make_bar_line(
"Mem",
mem.used,
mem.total,
inner.width,
unicode,
theme,
));
if mem.swap_total > 0 {
lines.push(make_bar_line(
"Swp",
mem.swap_used,
mem.swap_total,
inner.width,
unicode,
theme,
));
}
let para = Paragraph::new(lines);
frame.render_widget(para, inner);
}
fn make_bar_line(
label: &str,
used: u64,
total: u64,
width: u16,
_unicode: bool,
theme: &Theme,
) -> Line<'static> {
let pct = if total > 0 {
(used as f64 / total as f64 * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
let used_gb = format_bytes_gb(used);
let total_gb = format_bytes_gb(total);
let info = format!("{pct:.0}% {used_gb}/{total_gb}G");
build_htop_bar(label, 5, info, pct, width, theme)
}
fn draw_cpu_bars(
frame: &mut Frame,
area: Rect,
snapshot: &SystemSnapshot,
unicode: bool,
theme: &Theme,
) {
let block = Block::default()
.title(" CPU ")
.title_style(
Style::default()
.fg(theme.accent_primary)
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(theme.text_dim));
let inner = block.inner(area);
frame.render_widget(block, area);
let cores = &snapshot.cpu.cores;
if cores.is_empty() {
return;
}
if cores.len() <= 16 {
let lines: Vec<Line<'static>> = cores
.iter()
.map(|c| make_cpu_bar_line(c, inner.width, unicode, theme))
.collect();
frame.render_widget(Paragraph::new(lines), inner);
} else {
let [left_area, right_area] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas(inner);
let mid = cores.len().div_ceil(2);
let left_lines: Vec<Line<'static>> = cores[..mid]
.iter()
.map(|c| make_cpu_bar_line(c, left_area.width, unicode, theme))
.collect();
let right_lines: Vec<Line<'static>> = cores[mid..]
.iter()
.map(|c| make_cpu_bar_line(c, right_area.width, unicode, theme))
.collect();
frame.render_widget(Paragraph::new(left_lines), left_area);
frame.render_widget(Paragraph::new(right_lines), right_area);
}
}
fn make_cpu_bar_line(
core: &CoreSnapshot,
width: u16,
_unicode: bool,
theme: &Theme,
) -> Line<'static> {
let pct = core.usage.clamp(0.0, 100.0) as f64;
let info = format!("{pct:.1}%");
build_htop_bar(&core.name, 6, info, pct, width, theme)
}
fn build_htop_bar(
label: &str,
label_w: usize,
info: String,
pct: f64,
width: u16,
theme: &Theme,
) -> Line<'static> {
let label_str = format!("{:<width$}", label, width = label_w);
let bar_w = (width as usize)
.saturating_sub(label_w + 2) .max(1);
let info = if info.len() >= bar_w {
info.chars().take(bar_w).collect::<String>()
} else {
info
};
let info_len = info.len();
let max_filled = bar_w.saturating_sub(info_len);
let filled = ((max_filled as f64) * (pct / 100.0)).round() as usize;
let filled = filled.min(max_filled);
let g_end = ((max_filled as f64) * 0.50).round() as usize;
let y_end = ((max_filled as f64) * 0.80).round() as usize;
let green_n = filled.min(g_end);
let yellow_n = if filled > g_end {
(filled - g_end).min(y_end - g_end)
} else {
0
};
let red_n = filled.saturating_sub(y_end);
let empty = bar_w - filled - info_len;
let mut spans: Vec<Span<'static>> = vec![
Span::styled(
label_str,
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::styled("[", Style::default().fg(theme.accent_primary)),
];
if green_n > 0 {
spans.push(Span::styled(
"|".repeat(green_n),
Style::default().fg(theme.success),
));
}
if yellow_n > 0 {
spans.push(Span::styled(
"|".repeat(yellow_n),
Style::default().fg(theme.warning),
));
}
if red_n > 0 {
spans.push(Span::styled(
"|".repeat(red_n),
Style::default().fg(theme.danger),
));
}
spans.push(Span::raw(" ".repeat(empty)));
spans.push(Span::styled(info, Style::default().fg(theme.text_dim)));
spans.push(Span::styled("]", Style::default().fg(theme.accent_primary)));
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::AppState;
use ratatui::{Terminal, backend::TestBackend};
fn render_with(app: &AppState, width: u16, height: u16) -> ratatui::buffer::Buffer {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
let _theme = super::super::theme::Theme::new(crate::terminal::ColorSupport::TrueColor);
terminal
.draw(|frame| {
crate::ui::draw_root(frame, app)
})
.unwrap();
terminal.backend().buffer().clone()
}
fn buffer_line_text(buf: &ratatui::buffer::Buffer, row: u16) -> String {
let width = buf.area.width;
(0..width)
.map(|col| buf.cell((col, row)).map(|c| c.symbol()).unwrap_or(" "))
.collect::<String>()
.trim_end()
.to_string()
}
fn buffer_contains(buf: &ratatui::buffer::Buffer, needle: &str) -> bool {
let height = buf.area.height;
(0..height).any(|row| buffer_line_text(buf, row).contains(needle))
}
fn make_test_snapshot(
core_count: usize,
running_count: usize,
) -> muxtop_core::system::SystemSnapshot {
use muxtop_core::process::ProcessInfo;
use muxtop_core::system::*;
let cores = (0..core_count)
.map(|i| CoreSnapshot {
name: format!("cpu{i}"),
usage: (i as f32 * 10.0) % 100.0,
frequency: 3600,
})
.collect();
let mut processes = Vec::new();
for i in 0..5 {
processes.push(ProcessInfo {
pid: i as u32,
parent_pid: None,
name: format!("proc{i}"),
command: format!("/usr/bin/proc{i}"),
user: "user".to_string(),
cpu_percent: 10.0,
memory_bytes: 1000,
memory_percent: 1.0,
status: if i < running_count {
"Running".to_string()
} else {
"Sleeping".to_string()
},
});
}
SystemSnapshot {
cpu: CpuSnapshot {
global_usage: 42.5,
cores,
},
memory: MemorySnapshot {
total: 16_000_000_000,
used: 8_000_000_000,
available: 8_000_000_000,
swap_total: 4_000_000_000,
swap_used: 1_000_000_000,
},
load: LoadSnapshot {
one: 2.31,
five: 1.87,
fifteen: 1.42,
uptime_secs: 90061,
},
processes,
networks: muxtop_core::network::NetworkSnapshot {
interfaces: vec![],
total_rx: 0,
total_tx: 0,
},
timestamp_ms: 0,
}
}
#[test]
fn test_general_tab_callable() {
let app = AppState::new();
let buf = render_with(&app, 80, 24);
assert!(!buffer_contains(&buf, "[General view"));
}
#[test]
fn test_general_no_data_shows_waiting() {
let app = AppState::new();
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "Waiting for data..."));
}
#[test]
fn test_general_three_zones_with_data() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let buf = render_with(&app, 80, 24);
assert!(!buffer_contains(&buf, "Waiting for data..."));
}
#[test]
fn test_format_uptime_zero() {
assert_eq!(format_uptime(0), "0d 0h 0m");
}
#[test]
fn test_format_uptime_complex() {
assert_eq!(format_uptime(90061), "1d 1h 1m");
}
#[test]
fn test_format_uptime_hours_only() {
assert_eq!(format_uptime(7200), "0d 2h 0m");
}
#[test]
fn test_system_info_tasks_count() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "Tasks: 5 (2 running)"));
}
#[test]
fn test_system_info_load_averages() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "2.31"));
assert!(buffer_contains(&buf, "1.87"));
assert!(buffer_contains(&buf, "1.42"));
}
#[test]
fn test_system_info_pipe_separated() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "Tasks: 5 (2 running)"));
}
#[test]
fn test_format_bytes_gb() {
let result = format_bytes_gb(16_000_000_000);
assert!(
result.contains('.'),
"Should contain decimal point: {result}"
);
assert!(!result.is_empty());
}
#[test]
fn test_format_bytes_gb_zero() {
assert_eq!(format_bytes_gb(0), "0.0");
}
#[test]
fn test_memory_bar_shows_values() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "Mem"));
assert!(buffer_contains(&buf, "%"));
}
#[test]
fn test_memory_swap_shown_when_active() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "Swp"));
}
#[test]
fn test_memory_swap_hidden_when_zero() {
let mut app = AppState::new();
let mut snap = make_test_snapshot(4, 2);
snap.memory.swap_total = 0;
snap.memory.swap_used = 0;
app.apply_snapshot(snap);
let buf = render_with(&app, 80, 24);
assert!(!buffer_contains(&buf, "Swp"));
}
#[test]
fn test_cpu_bars_show_core_labels() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "cpu0"));
assert!(buffer_contains(&buf, "cpu1"));
assert!(buffer_contains(&buf, "cpu2"));
assert!(buffer_contains(&buf, "cpu3"));
}
#[test]
fn test_cpu_bars_show_percentages() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "%"));
}
#[test]
fn test_cpu_bars_zero_cores_no_panic() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(0, 0));
let _buf = render_with(&app, 80, 24);
}
#[test]
fn test_cpu_bars_condense_20_cores() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(20, 0));
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "cpu0"));
assert!(buffer_contains(&buf, "cpu10"));
}
#[test]
fn test_cpu_bars_condense_odd_17_cores() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(17, 0));
let buf = render_with(&app, 100, 30);
assert!(buffer_contains(&buf, "cpu0"));
assert!(buffer_contains(&buf, "cpu8"));
assert!(buffer_contains(&buf, "cpu16"));
}
#[test]
fn test_cpu_bars_condense_odd_21_cores() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(21, 0));
let buf = render_with(&app, 100, 30);
assert!(buffer_contains(&buf, "cpu0"));
assert!(buffer_contains(&buf, "cpu10"));
assert!(buffer_contains(&buf, "cpu20"));
}
#[test]
fn test_general_full_render_80x24() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let buf = render_with(&app, 80, 24);
assert!(buffer_contains(&buf, "CPU"));
assert!(buffer_contains(&buf, "Mem"));
assert!(buffer_contains(&buf, "Tasks"));
}
#[test]
fn test_general_tiny_terminal_no_panic() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let _buf = render_with(&app, 40, 6);
}
#[test]
fn test_general_large_terminal_no_panic() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let _buf = render_with(&app, 200, 50);
}
#[test]
fn test_general_replaces_placeholder() {
let mut app = AppState::new();
app.apply_snapshot(make_test_snapshot(4, 2));
let buf = render_with(&app, 80, 24);
assert!(!buffer_contains(&buf, "[General view"));
}
}