use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Sparkline, Table},
Frame,
};
use crate::{app::AppState, args::SortBy};
use humansize::{format_size, BINARY};
fn rate_color(bps: u64) -> Color {
match bps {
0 => Color::DarkGray,
1..=102_400 => Color::Green,
102_401..=1_048_576 => Color::Yellow,
1_048_577..=10_485_760 => Color::LightYellow,
_ => Color::Red,
}
}
fn fmt_rate(bps: u64) -> String {
if bps == 0 {
return "—".into();
}
format!("{}/s", format_size(bps, BINARY))
}
fn fmt_bytes(b: u64) -> String {
if b == 0 {
return "—".into();
}
format_size(b, BINARY).to_string()
}
fn sort_indicator(col: &SortBy, current: &SortBy) -> &'static str {
if col == current {
" ▼"
} else {
""
}
}
pub fn draw(f: &mut Frame, state: &AppState) {
let area = f.size();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), Constraint::Length(3), ])
.split(area);
draw_header(f, chunks[0], state);
draw_table(f, chunks[1], state);
draw_statusbar(f, chunks[2], state);
draw_footer(f, chunks[3], state);
if state.filter_editing {
draw_filter_popup(f, area, state);
}
if state.paused {
draw_pause_overlay(f, chunks[1]);
}
if state.show_help {
draw_help(f, area);
}
}
fn draw_header(f: &mut Frame, area: Rect, state: &AppState) {
let snap = &state.snapshot;
let mode = if state.cumulative { "CUMUL" } else { "RATE" };
let sort = sort_label(&state.args.sort).to_string();
let filter_lbl = state
.args
.filter
.as_deref()
.map(|f| format!(" filter:\"{}\"", f))
.unwrap_or_default();
let uptime = fmt_uptime(state.uptime_secs());
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(Span::styled(
format!(" nettop v{} ", env!("CARGO_PKG_VERSION")),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
let line = Line::from(vec![
Span::styled("▲ ", Style::default().fg(Color::Green)),
Span::styled(
format!("{}/s", format_size(snap.total_sent, BINARY)),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("▼ ", Style::default().fg(Color::Cyan)),
Span::styled(
format!("{}/s", format_size(snap.total_recv, BINARY)),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(
" │ procs: {} │ sort: {} │ mode: {} │ up: {}{}",
snap.entries.len(),
sort,
mode,
uptime,
filter_lbl
),
Style::default().fg(Color::DarkGray),
),
Span::styled(" [h] help", Style::default().fg(Color::DarkGray)),
]);
let para = Paragraph::new(line).block(block).alignment(Alignment::Left);
f.render_widget(para, area);
}
fn draw_table(f: &mut Frame, area: Rect, state: &AppState) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(80), Constraint::Length(22)])
.split(area);
draw_process_table(f, cols[0], state);
draw_sparklines(f, cols[1], state);
}
fn draw_process_table(f: &mut Frame, area: Rect, state: &AppState) {
let sort = &state.args.sort;
let headers = [
format!("PID{}", sort_indicator(&SortBy::Pid, sort)),
format!("PROCESS{}", sort_indicator(&SortBy::Name, sort)),
format!("▲ SENT/s{}", sort_indicator(&SortBy::Sent, sort)),
format!("▼ RECV/s{}", sort_indicator(&SortBy::Recv, sort)),
format!("TOTAL/s{}", sort_indicator(&SortBy::TotalRate, sort)),
format!("▲ SENT{}", sort_indicator(&SortBy::SentTotal, sort)),
format!("▼ RECV{}", sort_indicator(&SortBy::RecvTotal, sort)),
];
let header = Row::new(headers.iter().map(|h| {
Cell::from(h.as_str()).style(
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
)
}))
.height(1);
let rows: Vec<Row> = state
.snapshot
.entries
.iter()
.map(|e| {
let active = if state.cumulative {
e.total_cumulative() > 0
} else {
e.total_rate() > 0
};
let dim = Style::default().fg(Color::DarkGray);
Row::new(vec![
Cell::from(e.pid.to_string()).style(if active {
Style::default().fg(Color::DarkGray)
} else {
dim
}),
Cell::from(truncate(&e.name, 18)).style(if active {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
dim
}),
Cell::from(fmt_rate(e.sent_rate))
.style(Style::default().fg(rate_color(e.sent_rate))),
Cell::from(fmt_rate(e.recv_rate))
.style(Style::default().fg(rate_color(e.recv_rate))),
Cell::from(fmt_rate(e.total_rate())).style(
Style::default()
.fg(rate_color(e.total_rate()))
.add_modifier(Modifier::BOLD),
),
Cell::from(fmt_bytes(e.sent_total)).style(Style::default().fg(Color::Yellow)),
Cell::from(fmt_bytes(e.recv_total)).style(Style::default().fg(Color::Cyan)),
])
})
.collect();
let widths = [
Constraint::Length(7),
Constraint::Length(18),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(11),
Constraint::Length(11),
];
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
" processes ",
Style::default().fg(Color::DarkGray),
)),
)
.highlight_style(
Style::default()
.bg(Color::Rgb(40, 40, 60))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut ts = state.table_state.clone();
f.render_stateful_widget(table, area, &mut ts);
}
fn draw_sparklines(f: &mut Frame, area: Rect, state: &AppState) {
let outer = Block::default()
.borders(Borders::TOP | Borders::RIGHT | Borders::BOTTOM)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
" activity ",
Style::default().fg(Color::DarkGray),
));
f.render_widget(outer, area);
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
let visible = inner.height as usize;
let selected = state.table_state.selected().unwrap_or(0);
let entries = &state.snapshot.entries;
let start = selected
.saturating_sub(visible / 2)
.min(entries.len().saturating_sub(visible));
let end = (start + visible).min(entries.len());
for (i, entry) in entries[start..end].iter().enumerate() {
let row_area = Rect {
x: inner.x,
y: inner.y + i as u16,
width: inner.width,
height: 1,
};
if row_area.y >= area.y + area.height {
break;
}
let data: Vec<u64> = entry.history.to_vec();
let color = rate_color(entry.total_rate());
let spark = Sparkline::default()
.data(&data)
.style(Style::default().fg(color));
f.render_widget(spark, row_area);
}
}
fn draw_statusbar(f: &mut Frame, area: Rect, state: &AppState) {
let snap = &state.snapshot;
let sel = state.table_state.selected().unwrap_or(0);
let selected_info = if sel < snap.entries.len() {
let e = &snap.entries[sel];
format!(
" selected: {} (pid {}) sent: {} recv: {} total: {}",
e.name,
e.pid,
fmt_rate(e.sent_rate),
fmt_rate(e.recv_rate),
fmt_rate(e.total_rate())
)
} else {
String::new()
};
let line = Line::from(vec![
Span::styled(
format!(" {}/{} ", sel + 1, snap.entries.len()),
Style::default().fg(Color::DarkGray),
),
Span::styled(selected_info, Style::default().fg(Color::White)),
]);
let para = Paragraph::new(line).style(Style::default().bg(Color::Rgb(20, 20, 30)));
f.render_widget(para, area);
}
fn draw_footer(f: &mut Frame, area: Rect, _state: &AppState) {
let pairs = [
("[q]", "Quit"),
("[p]", "Pause"),
("[r]", "Reset"),
("[s]", "Sort"),
("[f]", "Filter"),
("[Tab]", "Rate/Cum"),
("[↑↓/jk]", "Scroll"),
("[g/G]", "Top/Bot"),
("[h/?]", "Help"),
];
let mut spans: Vec<Span> = vec![Span::raw(" ")];
for (key, label) in &pairs {
spans.push(Span::styled(
*key,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!(" {} ", label),
Style::default().fg(Color::DarkGray),
));
}
let para = Paragraph::new(Line::from(spans)).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(para, area);
}
fn draw_filter_popup(f: &mut Frame, area: Rect, state: &AppState) {
let popup = centered_rect(50, 14, area);
f.render_widget(Clear, popup);
let display = format!(" {}█", state.filter_buf);
let para = Paragraph::new(display)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(Span::styled(
" Filter (Enter=apply Esc=cancel Del=clear) ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
)
.style(Style::default().fg(Color::White));
f.render_widget(para, popup);
}
fn draw_pause_overlay(f: &mut Frame, area: Rect) {
let popup = centered_rect(28, 35, area);
f.render_widget(Clear, popup);
let para = Paragraph::new("\n ⏸ PAUSED\n\n [p] to resume ")
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.alignment(Alignment::Center)
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
f.render_widget(para, popup);
}
fn draw_help(f: &mut Frame, area: Rect) {
let popup = centered_rect(52, 70, area);
f.render_widget(Clear, popup);
let text = vec![
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
" NAVIGATION",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
help_row("↑ / k", "Scroll up"),
help_row("↓ / j", "Scroll down"),
help_row("g / Home", "Jump to top"),
help_row("G / End", "Jump to bottom"),
help_row("PgUp/PgDn", "Scroll 10 rows"),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
" CONTROLS",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
help_row("s", "Cycle sort column"),
help_row("Tab", "Toggle rate / cumulative"),
help_row("f", "Filter by process name"),
help_row("p", "Pause / resume"),
help_row("r", "Reset cumulative counters"),
help_row("q / Ctrl+C", "Quit"),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
" SORT COLUMNS",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
help_row("total-rate", "Total bandwidth/s (default)"),
help_row("sent", "Sent bytes/s"),
help_row("recv", "Received bytes/s"),
help_row("sent-total", "Cumulative sent"),
help_row("recv-total", "Cumulative received"),
help_row("name", "Process name"),
help_row("pid", "Process ID"),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
" RATE COLORS",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::raw(" "),
Span::styled("■ green ", Style::default().fg(Color::Green)),
Span::raw("< 100 KB/s "),
Span::styled("■ yellow ", Style::default().fg(Color::Yellow)),
Span::raw("< 1 MB/s"),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("■ bright ", Style::default().fg(Color::LightYellow)),
Span::raw("< 10 MB/s "),
Span::styled("■ red ", Style::default().fg(Color::Red)),
Span::raw("> 10 MB/s"),
]),
Line::from(vec![Span::raw("")]),
Line::from(vec![Span::styled(
" Press any key to close",
Style::default().fg(Color::DarkGray),
)]),
];
let para = Paragraph::new(text).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(Span::styled(
" nettop — Help ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
);
f.render_widget(para, popup);
}
fn help_row(key: &'static str, desc: &'static str) -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(format!("{:<14}", key), Style::default().fg(Color::Yellow)),
Span::styled(desc, Style::default().fg(Color::White)),
])
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vert[1])[1]
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
format!("…{}", &s[s.len().saturating_sub(max - 1)..])
}
fn sort_label(s: &SortBy) -> &'static str {
match s {
SortBy::Pid => "pid",
SortBy::Name => "name",
SortBy::Sent => "sent/s",
SortBy::Recv => "recv/s",
SortBy::TotalRate => "total/s",
SortBy::SentTotal => "sent-total",
SortBy::RecvTotal => "recv-total",
}
}
fn fmt_uptime(secs: u64) -> String {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
if h > 0 {
format!("{}h{:02}m{:02}s", h, m, s)
} else if m > 0 {
format!("{}m{:02}s", m, s)
} else {
format!("{}s", s)
}
}