use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::tui::app::App;
use crate::tui::state::{LiveMode, Pane, SelectionMode};
const SEP_RIGHT: &str = "\u{E0B0}"; const SEP_LEFT: &str = "\u{E0B2}";
const MODE_BG: Color = Color::Blue;
const MODE_FG: Color = Color::White;
const INFO_BG: Color = Color::Indexed(238); const INFO_FG: Color = Color::White;
const MID_BG: Color = Color::Indexed(236); const MID_FG: Color = Color::Indexed(250); const RIGHT_BG: Color = Color::Indexed(238);
const RIGHT_FG: Color = Color::White;
const POS_BG: Color = Color::Green;
const POS_FG: Color = Color::Black;
const LIVE_BG: Color = Color::Green;
const LIVE_FG: Color = Color::Black;
const PAUSED_BG: Color = Color::Yellow;
const PAUSED_FG: Color = Color::Black;
pub fn render(f: &mut Frame, app: &mut App, area: Rect) {
let zoom = app.maximized_pane.is_some();
let pane_label = match app.active_pane {
Pane::PacketList if zoom => " PACKETS [Z] ",
Pane::PacketList => " PACKETS ",
Pane::DetailTree if zoom => " DETAIL [Z] ",
Pane::DetailTree => " DETAIL ",
Pane::HexDump if zoom => " HEX [Z] ",
Pane::HexDump => " HEX ",
Pane::FilterInput => " FILTER ",
Pane::TreeSearch => " SEARCH ",
Pane::YankPrompt => " YANK ",
Pane::CommandMode => " COMMAND ",
};
let pane_label = if let Some(sel) = &app.detail_tree.selection {
match sel.mode {
SelectionMode::Char => " -- VISUAL -- ",
SelectionMode::Line => " -- VISUAL LINE -- ",
}
} else {
pane_label
};
let total = app.total_count();
let displayed = app.displayed_count();
let selected_num = app.selected_number();
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(
pane_label,
Style::default()
.fg(MODE_FG)
.bg(MODE_BG)
.add_modifier(Modifier::BOLD),
));
if let Some(live_mode) = app.live_mode {
let (label, bg) = match live_mode {
LiveMode::Live => ("\u{25B6} Live", LIVE_BG),
LiveMode::Paused => ("\u{23F8} Paused", PAUSED_BG),
LiveMode::Complete => ("\u{2713} Complete", INFO_BG),
};
let fg = match live_mode {
LiveMode::Complete => INFO_FG,
_ => {
if bg == LIVE_BG {
LIVE_FG
} else {
PAUSED_FG
}
}
};
spans.push(Span::styled(SEP_RIGHT, Style::default().fg(MODE_BG).bg(bg)));
spans.push(Span::styled(
format!(" {label} "),
Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(SEP_RIGHT, Style::default().fg(bg).bg(INFO_BG)));
} else if app.index_progress.is_some() || app.bg_indexer.is_some() {
let pct = if let Some(ref bg) = app.bg_indexer {
(bg.fraction() * 100.0) as u32
} else if let Some(ref progress) = app.index_progress {
(progress.fraction() * 100.0) as u32
} else {
0
};
let label = format!("\u{23F3} Indexing {pct}%");
spans.push(Span::styled(
SEP_RIGHT,
Style::default().fg(MODE_BG).bg(PAUSED_BG),
));
spans.push(Span::styled(
format!(" {label} "),
Style::default()
.fg(PAUSED_FG)
.bg(PAUSED_BG)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
SEP_RIGHT,
Style::default().fg(PAUSED_BG).bg(INFO_BG),
));
} else {
spans.push(Span::styled(
SEP_RIGHT,
Style::default().fg(MODE_BG).bg(INFO_BG),
));
}
spans.push(Span::styled(
format!(" {} ", app.file_name),
Style::default().fg(INFO_FG).bg(INFO_BG),
));
spans.push(Span::styled(
SEP_RIGHT,
Style::default().fg(INFO_BG).bg(MID_BG),
));
let time_label = app.time_format.label();
let count_text = if !app.filter.applied.is_empty() {
format!(" {displayed}/{total} [{time_label}] ")
} else {
format!(" {total} pkts [{time_label}] ")
};
spans.push(Span::styled(
count_text,
Style::default().fg(MID_FG).bg(MID_BG),
));
let sel_pkt_idx = app
.filtered_indices
.get(app.packet_list.selected)
.copied()
.unwrap_or(usize::MAX);
if let Some(summary) = app.summary_cache.get(&sel_pkt_idx) {
spans.push(Span::styled(
format!(
" {} {} \u{2192} {} ",
summary.protocol, summary.source, summary.destination
),
Style::default().fg(MID_FG).bg(MID_BG),
));
} else {
spans.push(Span::styled(" ", Style::default().bg(MID_BG)));
}
let mut right_spans: Vec<Span> = Vec::new();
if !app.pending_count.is_empty() {
right_spans.push(Span::styled(
SEP_LEFT,
Style::default().fg(Color::Yellow).bg(MID_BG),
));
right_spans.push(Span::styled(
format!(" {} ", app.pending_count),
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
right_spans.push(Span::styled(
SEP_LEFT,
Style::default().fg(RIGHT_BG).bg(Color::Yellow),
));
} else {
right_spans.push(Span::styled(
SEP_LEFT,
Style::default().fg(RIGHT_BG).bg(MID_BG),
));
}
right_spans.push(Span::styled(
format!(" #{selected_num} "),
Style::default().fg(RIGHT_FG).bg(RIGHT_BG),
));
right_spans.push(Span::styled(
SEP_LEFT,
Style::default().fg(POS_BG).bg(RIGHT_BG),
));
right_spans.push(Span::styled(
format!(" {displayed} "),
Style::default()
.fg(POS_FG)
.bg(POS_BG)
.add_modifier(Modifier::BOLD),
));
let left_used: usize = spans.iter().map(|s| s.content.chars().count()).sum();
let right_len: usize = right_spans.iter().map(|s| s.content.chars().count()).sum();
let fill_width = (area.width as usize).saturating_sub(left_used + right_len);
if fill_width > 0 {
spans.push(Span::styled(
" ".repeat(fill_width),
Style::default().bg(MID_BG),
));
}
spans.extend(right_spans);
let line = Line::from(spans);
let paragraph = Paragraph::new(line);
f.render_widget(paragraph, area);
}
#[cfg(all(test, feature = "tui"))]
mod tests {
use super::*;
use crate::tui::test_util::{make_test_app, render_to_string};
#[test]
fn status_bar_renders_mode_and_file() {
let mut app = make_test_app(3);
let dump = render_to_string(120, 1, |f| {
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 1,
};
render(f, &mut app, area);
});
assert!(dump.contains("PACKETS"), "dump: {dump}");
assert!(dump.contains("test.pcap"), "dump: {dump}");
assert!(dump.contains("3 pkts"), "dump: {dump}");
assert!(dump.contains("Abs"), "dump: {dump}");
}
#[test]
fn status_bar_with_applied_filter_shows_ratio() {
let mut app = make_test_app(3);
app.filter.applied = "udp".into();
let dump = render_to_string(120, 1, |f| {
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 1,
};
render(f, &mut app, area);
});
assert!(dump.contains("3/3"), "dump: {dump}");
}
#[test]
fn status_bar_command_mode_label() {
let mut app = make_test_app(1);
app.active_pane = Pane::CommandMode;
let dump = render_to_string(120, 1, |f| {
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 1,
};
render(f, &mut app, area);
});
assert!(dump.contains("COMMAND"), "dump: {dump}");
}
}