use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use crate::app::App;
use crate::units::{fmt_bytes, fmt_rate};
use super::theme;
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let block = theme::panel_block("Network", false);
let inner = block.inner(area);
f.render_widget(block, area);
let Some(fast) = app.fast.as_ref() else {
f.render_widget(Paragraph::new("n/a").style(Style::default().fg(theme::DIM)), inner);
return;
};
let rate_area = Rect { height: inner.height.min(1), ..inner };
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!("▼ {}", fmt_rate(fast.net_rx_rate)), Style::default().fg(theme::ACCENT)),
Span::styled(
format!(" ▲ {}", fmt_rate(fast.net_tx_rate)),
Style::default().fg(theme::gradient(0.55)),
),
Span::styled(
format!(" ∑ ▼{} ▲{}", fmt_bytes(fast.net_rx_total), fmt_bytes(fast.net_tx_total)),
Style::default().fg(theme::DIM),
),
])),
rate_area,
);
let bands_height = inner.height.saturating_sub(rate_area.height);
if bands_height == 0 {
return;
}
let bands_area = Rect { y: rate_area.y + rate_area.height, height: bands_height, ..inner };
let peak = app
.net_hist
.iter()
.map(|(rx, tx)| rx.max(tx))
.fold(1024.0, f64::max) * 1.2;
let max = peak as u64;
let down: Vec<u64> = app.net_hist.iter().map(|(rx, _)| rx as u64).collect();
if bands_height == 1 {
render_band(f, bands_area, "▼", &down, max, theme::ACCENT);
return;
}
let up: Vec<u64> = app.net_hist.iter().map(|(_, tx)| tx as u64).collect();
let bands = Layout::vertical([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).split(bands_area);
render_band(f, bands[0], "▼", &down, max, theme::ACCENT);
render_band(f, bands[1], "▲", &up, max, theme::gradient(0.55));
}
fn render_band(f: &mut Frame, area: Rect, marker: &str, data: &[u64], max: u64, color: Color) {
let cols = Layout::horizontal([Constraint::Length(2), Constraint::Min(1)]).split(area);
let gutter = cols[0];
let marker_area = Rect { y: gutter.y + gutter.height.saturating_sub(1), height: 1, ..gutter };
f.render_widget(
Paragraph::new(marker).style(Style::default().fg(theme::DIM)),
marker_area,
);
super::spark::render(f, cols[1], data, max, Style::default().fg(color));
}
#[cfg(test)]
mod tests {
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::Terminal;
use crate::app::App;
use super::theme;
fn draw() -> Vec<String> {
draw_at_inner_height(6)
}
fn draw_at_inner_height(inner_height: u16) -> Vec<String> {
let mut t = Terminal::new(TestBackend::new(40, inner_height + 2)).unwrap();
let mut app = App::demo();
for i in 0..30 {
app.net_hist.push((i as f64 * 4000.0, i as f64 * 800.0));
}
t.draw(|f| super::render(f, f.area(), &app)).unwrap();
let buf = t.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width).map(|x| buf[(x, y)].symbol().to_string()).collect::<String>()
})
.collect()
}
#[test]
fn renders_two_labeled_sparkline_bands() {
let lines = draw();
let joined = lines.join("\n");
assert!(
joined.chars().any(|c| "▁▂▃▄▅▆▇█".contains(c)),
"no sparkline bars rendered"
);
let last_row = |m: char| lines.iter().rposition(|l| l.contains(m)).unwrap();
let down_row = last_row('▼');
let up_row = last_row('▲');
assert_ne!(down_row, up_row, "download and upload must occupy separate rows");
assert!(down_row < up_row, "download band should sit above upload band");
}
#[test]
fn rate_line_survives_at_every_inner_height() {
for inner_height in [1, 2, 3, 6] {
let joined = draw_at_inner_height(inner_height).join("\n");
assert!(
joined.contains("/s"),
"rate readout missing at inner height {inner_height}"
);
}
}
#[test]
fn inner_height_1_renders_no_bands() {
let joined = draw_at_inner_height(1).join("\n");
assert!(
!joined.chars().any(|c| "▁▂▃▄▅▆▇█".contains(c)),
"no room left for bands at inner height 1, but a sparkline bar rendered"
);
}
#[test]
fn inner_height_2_renders_download_band_only() {
let lines = draw_at_inner_height(2);
let joined = lines.join("\n");
assert!(
joined.chars().any(|c| "▁▂▃▄▅▆▇█".contains(c)),
"download band should render when exactly one row remains"
);
let down_rows = lines.iter().filter(|l| l.contains('▼')).count();
let up_rows = lines.iter().filter(|l| l.contains('▲')).count();
assert_eq!(down_rows, 2, "expected rate line + single download band row, got {down_rows}");
assert_eq!(up_rows, 1, "upload band should not render when only one row remains, got {up_rows} rows with '▲'");
}
#[test]
fn both_network_bands_ramp_within_their_own_hue() {
let mut t = Terminal::new(TestBackend::new(10, 2)).unwrap();
t.draw(|f| {
let area = f.area();
let down = Rect { height: 1, ..area };
let up = Rect { y: area.y + 1, height: 1, ..area };
super::render_band(f, down, "▼", &[8], 8, theme::ACCENT);
super::render_band(f, up, "▲", &[8], 8, theme::gradient(0.55));
})
.unwrap();
let buf = t.backend().buffer().clone();
let down_bar = buf[(9, 0)].bg;
let up_bar = buf[(9, 1)].bg;
assert_eq!(down_bar, theme::ACCENT, "download band's full-height bar should be its own accent colour");
assert_eq!(up_bar, theme::gradient(0.55), "upload band's full-height bar should be its own gradient colour, not accent");
assert_ne!(down_bar, up_bar, "the two bands must not collapse to the same colour");
}
#[test]
fn inner_height_3_renders_both_bands() {
let lines = draw_at_inner_height(3);
let last_row = |m: char| lines.iter().rposition(|l| l.contains(m)).unwrap();
let down_row = last_row('▼');
let up_row = last_row('▲');
assert_ne!(down_row, up_row, "both bands should render once two rows remain");
assert!(down_row < up_row);
}
}