use std::io::Stdout;
use std::path::PathBuf;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Borders, Clear, HighlightSpacing, List, ListItem, ListState, Paragraph, Wrap,
};
use crate::cli::tui::client::SaveFormat;
use crate::cli::tui::filebrowser::FileBrowser;
use crate::cli::tui::iso;
use crate::cli::tui::menu::{
self, DESKTOP_BG, Menu, MenuAction, MenuBar, MenuEntry, MenuItem, PANEL_BG, PANEL_BORDER,
PANEL_FG, PANEL_TITLE, SURFACE_ACCENT, SURFACE_BG, SURFACE_FG,
};
use crate::cli::tui::state::{
AppState, DeviceInfo, Entry, HistoryRecord, Mode, OverrideRow, PgnLoadRow, Progress,
};
const INTERVAL_OFF: u32 = 0;
fn default_interval_hint(pgn: u32) -> u32 {
match pgn {
127251 => 100,
127257 | 127250 => 100,
129025 | 129026 | 129029 => 250,
130306 => 100,
_ => 1000,
}
}
#[derive(Clone)]
pub enum Screen {
Devices,
DeviceDetail {
src: u8,
},
EntryDetail {
src: u8,
pgn: u32,
secondary: Option<String>,
history_pos: usize,
return_to: Box<Screen>,
},
TimeView,
PgnTop,
Nmea0183,
Overrides,
}
pub struct App {
pub screen: Screen,
pub devices_state: ListState,
pub detail_state: ListState,
pub nmea0183_state: ListState,
pub overrides_state: ListState,
pub time_state: ListState,
pub time_offset: usize,
pub pgn_top_state: ListState,
pub menu: MenuBar,
pub help_visible: bool,
pub splash: Option<u16>,
pub io_modal: Option<IoModal>,
pub file_browser: Option<FileBrowser>,
pub browse_dir: Option<PathBuf>,
pub pending_command: Option<PendingCommand>,
pub pending_override_forget: Option<(u8, u32)>,
pub entry_scroll: u16,
pub modal: Option<OverrideModal>,
pub toast: Option<String>,
pub should_quit: bool,
pub connecting_dismissed: bool,
pub last_acknowledged_error: Option<String>,
pub text_prompt: Option<TextPrompt>,
pub src_select: Option<SrcSelect>,
pub search_query: Option<String>,
pub filter_pgns: Option<Vec<u32>>,
pub filter_srcs: Option<Vec<u8>>,
}
pub struct TextPrompt {
pub kind: TextPromptKind,
pub buffer: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextPromptKind {
Search,
FilterPgns,
}
pub struct SrcSelect {
pub sources: Vec<(u8, String)>,
pub selected: std::collections::HashSet<u8>,
pub cursor: usize,
}
pub struct IoModal {
pub input: String,
}
pub enum PendingCommand {
Connect {
host: String,
snapshot_port: u16,
stream_port: u16,
},
Load {
path: PathBuf,
},
Save {
path: PathBuf,
format: SaveFormat,
},
}
pub struct OverrideModal {
pub src: u8,
pub pgn: u32,
pub input: String,
pub manufacturer_code: Option<u16>,
pub industry_code: Option<u8>,
pub description: Option<String>,
}
impl App {
pub fn new() -> Self {
let mut devices_state = ListState::default();
devices_state.select(Some(0));
let mut detail_state = ListState::default();
detail_state.select(Some(0));
let mut time_state = ListState::default();
time_state.select(Some(0));
let mut nmea0183_state = ListState::default();
nmea0183_state.select(Some(0));
let mut overrides_state = ListState::default();
overrides_state.select(Some(0));
let mut pgn_top_state = ListState::default();
pgn_top_state.select(Some(0));
Self {
screen: Screen::Devices,
devices_state,
detail_state,
nmea0183_state,
overrides_state,
time_state,
time_offset: 0,
pgn_top_state,
menu: MenuBar::new(),
help_visible: false,
splash: Some(0),
io_modal: None,
file_browser: None,
browse_dir: None,
pending_command: None,
pending_override_forget: None,
entry_scroll: 0,
modal: None,
toast: None,
should_quit: false,
connecting_dismissed: false,
last_acknowledged_error: None,
text_prompt: None,
src_select: None,
search_query: None,
filter_pgns: None,
filter_srcs: None,
}
}
fn row_visible(&self, h: &HistoryRecord) -> bool {
self.filter_pgns
.as_ref()
.is_none_or(|list| list.contains(&h.pgn))
&& self
.filter_srcs
.as_ref()
.is_none_or(|list| list.contains(&h.src))
}
fn no_history_filter(&self) -> bool {
self.filter_pgns.is_none() && self.filter_srcs.is_none()
}
pub fn visible_history_indices(&self, state: &AppState) -> Vec<usize> {
if self.no_history_filter() {
return (0..state.history.len()).collect();
}
state
.history
.iter()
.enumerate()
.filter(|(_, h)| self.row_visible(h))
.map(|(i, _)| i)
.collect()
}
pub fn visible_history_len(&self, state: &AppState) -> usize {
if self.no_history_filter() {
return state.history.len();
}
state.history.iter().filter(|h| self.row_visible(h)).count()
}
fn visible_window(&self, state: &AppState, offset: usize, len: usize) -> Vec<usize> {
if self.no_history_filter() {
let end = (offset + len).min(state.history.len());
return (offset..end).collect();
}
state
.history
.iter()
.enumerate()
.filter(|(_, h)| self.row_visible(h))
.skip(offset)
.take(len)
.map(|(i, _)| i)
.collect()
}
pub fn detail_rows(&self, src: u8, state: &AppState) -> Vec<Entry> {
let mut rows: Vec<Entry> = state.entries_for_src(src).into_iter().cloned().collect();
let mut present: std::collections::HashSet<u32> = rows.iter().map(|e| e.pgn).collect();
for ov in state.override_rows() {
if ov.interval_ms != INTERVAL_OFF || ov.src != src || present.contains(&ov.pgn) {
continue;
}
rows.push(synthesize_silenced_entry(&ov));
present.insert(ov.pgn);
}
let device_mfr = device_manufacturer(src, state);
let mut shown: std::collections::HashSet<(u32, Option<u16>)> = rows
.iter()
.map(|e| (e.pgn, proprietary_codes(e).0))
.collect();
for pgn in state.pgn_lists_for_src(src).tx {
if let Some(v) = advertised_variant(pgn, device_mfr)
&& shown.insert((pgn, variant_manufacturer(v)))
{
rows.push(synthesize_advertised_entry(v, src));
}
}
rows.sort_by(|a, b| {
a.pgn
.cmp(&b.pgn)
.then_with(|| a.secondary.cmp(&b.secondary))
});
rows
}
pub fn build_menus(&self, state: &AppState) -> Vec<Menu> {
let live = state.status.mode == Mode::Live;
let on_device_detail = matches!(self.screen, Screen::DeviceDetail { .. });
let on_timeview = matches!(self.screen, Screen::TimeView);
let not_devices = !matches!(self.screen, Screen::Devices);
vec![
Menu {
title: "File",
hotkey: 'F',
entries: vec![
MenuItem::maybe(
"Connect…",
Some('C'),
"",
MenuAction::FileConnect,
state.save_progress.is_none(),
),
MenuItem::maybe(
"Load…",
Some('L'),
"",
MenuAction::FileLoad,
state.save_progress.is_none(),
),
MenuItem::maybe(
"Save…",
Some('S'),
"",
MenuAction::FileSave,
!state.history.is_empty()
&& state.status.snapshot_loaded
&& state.save_progress.is_none(),
),
MenuEntry::Separator,
MenuItem::new("Quit", Some('Q'), "q", MenuAction::Quit),
],
},
Menu {
title: "View",
hotkey: 'V',
entries: vec![
MenuItem::new("Devices", Some('D'), "d", MenuAction::ViewDevices),
MenuItem::new("Timeline", Some('T'), "t", MenuAction::ViewTimeline),
MenuItem::new("PGN Load", Some('P'), "p", MenuAction::ViewPgnLoad),
MenuItem::maybe(
"NMEA 0183 Filter",
Some('N'),
"n",
MenuAction::ViewNmea0183,
live,
),
MenuItem::maybe(
"PGN Overrides",
Some('O'),
"o",
MenuAction::ViewOverrides,
live,
),
MenuEntry::Separator,
MenuItem::maybe("Back", Some('B'), "Esc", MenuAction::Back, not_devices),
],
},
Menu {
title: "Device",
hotkey: 'D',
entries: vec![
MenuItem::maybe(
"Open Entry",
Some('O'),
"Enter",
MenuAction::OpenSelected,
on_device_detail,
),
MenuItem::maybe(
"ISO Request 126464",
Some('I'),
"i",
MenuAction::IsoRequest126464,
on_device_detail && live,
),
MenuItem::maybe(
"Override Interval…",
Some('V'),
"o",
MenuAction::OverrideInterval,
on_device_detail && live,
),
],
},
Menu {
title: "Search",
hotkey: 'S',
entries: vec![
MenuItem::maybe("Find…", Some('F'), "/", MenuAction::SearchFind, on_timeview),
MenuItem::maybe(
"Find Next",
Some('N'),
"n",
MenuAction::SearchNext,
on_timeview,
),
MenuItem::maybe(
"Find Previous",
Some('P'),
"N",
MenuAction::SearchPrev,
on_timeview,
),
MenuEntry::Separator,
MenuItem::maybe(
"Filter PGNs…",
Some('G'),
"f",
MenuAction::FilterPgns,
on_timeview,
),
MenuItem::maybe(
"Filter Sources…",
Some('S'),
"s",
MenuAction::FilterSources,
on_timeview,
),
],
},
Menu {
title: "Help",
hotkey: 'H',
entries: vec![
MenuItem::new("Keys", Some('K'), "F1", MenuAction::Help),
MenuItem::new("About…", Some('A'), "", MenuAction::About),
],
},
]
}
fn handle_menu_key(
&mut self,
key: KeyEvent,
state: &AppState,
writer: &crate::cli::tui::client::Writer,
) {
let menus = self.build_menus(state);
let Some(open) = self.menu.open else {
return;
};
match key.code {
KeyCode::Esc => {
self.menu.open = None;
}
KeyCode::Left | KeyCode::Char('h') => {
let n = menus.len();
let next = (open + n - 1) % n;
self.menu.open = Some(next);
self.menu.item = menus[next].first_item();
}
KeyCode::Right | KeyCode::Char('l') => {
let n = menus.len();
let next = (open + 1) % n;
self.menu.open = Some(next);
self.menu.item = menus[next].first_item();
}
KeyCode::Down | KeyCode::Char('j') => {
self.menu.item = menus[open].step(self.menu.item, 1);
}
KeyCode::Up | KeyCode::Char('k') => {
self.menu.item = menus[open].step(self.menu.item, -1);
}
KeyCode::Enter => {
if let Some(menu::MenuEntry::Item(item)) = menus[open].entries.get(self.menu.item)
&& item.enabled
{
let action = item.action;
self.menu.open = None;
self.run_menu_action(action, state, writer);
}
}
KeyCode::Char(c) => {
let lc = c.to_ascii_lowercase();
let hit = menus[open].entries.iter().find_map(|e| match e {
menu::MenuEntry::Item(it)
if it.enabled && it.hotkey.map(|h| h.to_ascii_lowercase()) == Some(lc) =>
{
Some(it.action)
}
_ => None,
});
if let Some(action) = hit {
self.menu.open = None;
self.run_menu_action(action, state, writer);
}
}
_ => {}
}
}
fn run_menu_action(
&mut self,
action: MenuAction,
state: &AppState,
writer: &crate::cli::tui::client::Writer,
) {
match action {
MenuAction::Quit => self.should_quit = true,
MenuAction::FileConnect => self.open_connect_modal(state),
MenuAction::FileLoad => self.open_file_browser(),
MenuAction::FileSave => self.open_save_browser(),
MenuAction::ViewDevices => self.screen = Screen::Devices,
MenuAction::ViewTimeline => self.screen = Screen::TimeView,
MenuAction::ViewPgnLoad => {
self.pgn_top_state.select(Some(0));
self.screen = Screen::PgnTop;
}
MenuAction::ViewNmea0183 => {
if state.status.mode == Mode::Live {
self.nmea0183_state.select(Some(0));
self.screen = Screen::Nmea0183;
}
}
MenuAction::ViewOverrides => {
if state.status.mode == Mode::Live {
self.overrides_state.select(Some(0));
self.screen = Screen::Overrides;
}
}
MenuAction::Back => self.go_back(),
MenuAction::OpenSelected => self.activate_selection(state, writer),
MenuAction::IsoRequest126464 => {
if let Screen::DeviceDetail { src } = self.screen
&& state.status.mode == Mode::Live
{
self.send_iso_126464(src, writer);
}
}
MenuAction::OverrideInterval => {
if let Screen::DeviceDetail { src } = self.screen
&& state.status.mode == Mode::Live
{
self.open_override_modal(src, state);
}
}
MenuAction::SearchFind => {
if matches!(self.screen, Screen::TimeView) {
self.text_prompt = Some(TextPrompt {
kind: TextPromptKind::Search,
buffer: self.search_query.clone().unwrap_or_default(),
});
}
}
MenuAction::SearchNext => {
if matches!(self.screen, Screen::TimeView) {
self.jump_search(state, 1);
}
}
MenuAction::SearchPrev => {
if matches!(self.screen, Screen::TimeView) {
self.jump_search(state, -1);
}
}
MenuAction::FilterPgns => {
if matches!(self.screen, Screen::TimeView) {
self.open_filter_pgns_prompt();
}
}
MenuAction::FilterSources => {
if matches!(self.screen, Screen::TimeView) {
self.open_src_select(state);
}
}
MenuAction::Help | MenuAction::About => self.help_visible = true,
}
}
fn go_back(&mut self) {
self.screen = match &self.screen {
Screen::EntryDetail { return_to, .. } => (**return_to).clone(),
Screen::Devices => Screen::Devices,
_ => Screen::Devices,
};
}
fn activate_selection(&mut self, state: &AppState, writer: &crate::cli::tui::client::Writer) {
match self.screen {
Screen::Devices => {
let devs = state.device_list();
if let Some(d) = self.devices_state.selected().and_then(|i| devs.get(i)) {
self.screen = Screen::DeviceDetail { src: d.src };
self.detail_state.select(Some(0));
}
}
Screen::DeviceDetail { src } => {
let rows = self.detail_rows(src, state);
if let Some(row) = self.detail_state.selected().and_then(|i| rows.get(i)) {
if row.count == 0 {
return;
}
self.entry_scroll = 0;
let history_pos = row.history_indices.len().saturating_sub(1);
self.screen = Screen::EntryDetail {
src,
pgn: row.pgn,
secondary: row.secondary.clone(),
history_pos,
return_to: Box::new(Screen::DeviceDetail { src }),
};
}
}
Screen::TimeView => self.drill_into_time_selection(state),
Screen::Nmea0183 => self.toggle_nmea0183_selection(state, writer),
Screen::Overrides => self.open_selected_override_modal(state),
Screen::EntryDetail { .. } | Screen::PgnTop => {}
}
}
fn open_selected_override_modal(&mut self, state: &AppState) {
let rows = state.override_rows();
if let Some(ov) = self.overrides_state.selected().and_then(|i| rows.get(i)) {
let desc = (!ov.description.is_empty()).then(|| ov.description.clone());
self.modal = Some(OverrideModal {
src: ov.src,
pgn: ov.pgn,
input: ov.interval_ms.to_string(),
manufacturer_code: ov.manufacturer_code,
industry_code: ov.industry_code,
description: desc,
});
}
}
fn delete_selected_override(
&mut self,
state: &AppState,
writer: &crate::cli::tui::client::Writer,
) {
let rows = state.override_rows();
if let Some(ov) = self.overrides_state.selected().and_then(|i| rows.get(i)) {
let (src, pgn) = (ov.src, ov.pgn);
if writer.send(iso::override_delete(src, pgn)) {
self.pending_override_forget = Some((src, pgn));
self.toast = Some(format!("Deleted override → src {src} pgn {pgn}"));
} else {
self.toast = Some("Writer channel closed".into());
}
}
}
fn send_iso_126464(&mut self, src: u8, writer: &crate::cli::tui::client::Writer) {
let line = iso::iso_request(src, 126464);
if writer.send(line) {
self.toast = Some(format!("Sent ISO Request 126464 → src {src}"));
} else {
self.toast = Some("Writer channel closed".into());
}
}
fn open_override_modal(&mut self, src: u8, state: &AppState) {
let rows = self.detail_rows(src, state);
if let Some(row) = self.detail_state.selected().and_then(|i| rows.get(i)) {
let (mfr, ind) = proprietary_codes(row);
let desc = (!row.description.is_empty()).then(|| row.description.clone());
self.modal = Some(OverrideModal {
src,
pgn: row.pgn,
input: default_interval_hint(row.pgn).to_string(),
manufacturer_code: mfr,
industry_code: ind,
description: desc,
});
}
}
fn open_connect_modal(&mut self, state: &AppState) {
let input = match state.status.mode {
Mode::Live => format!(
"{} {} {}",
state.status.host, state.status.snapshot_port, state.status.stream_port
),
Mode::Log => "localhost 2597 2598".to_string(),
};
self.io_modal = Some(IoModal { input });
}
fn browse_start(&self) -> PathBuf {
self.browse_dir
.clone()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("/"))
}
pub fn open_file_browser(&mut self) {
self.file_browser = Some(FileBrowser::open(self.browse_start()));
}
fn open_save_browser(&mut self) {
self.file_browser = Some(FileBrowser::open_save(
self.browse_start(),
"canboat-capture.json".to_string(),
SaveFormat::Analysed,
));
}
fn handle_file_browser_key(&mut self, key: KeyEvent) {
let mut result: Option<PendingCommand> = None;
let mut toast: Option<String> = None;
let mut close = false;
let new_dir;
{
let Some(fb) = self.file_browser.as_mut() else {
return;
};
if fb.save {
match key.code {
KeyCode::Esc => close = true,
KeyCode::Down => fb.move_selection(1),
KeyCode::Up => fb.move_selection(-1),
KeyCode::Left => fb.go_parent(),
KeyCode::Right => {
if let Some(path) = fb.activate()
&& let Some(name) = path.file_name()
{
fb.filename = name.to_string_lossy().into_owned();
}
}
KeyCode::Tab | KeyCode::BackTab => {
fb.format = match fb.format {
SaveFormat::Analysed => SaveFormat::Raw,
SaveFormat::Raw => SaveFormat::Analysed,
};
fb.filename = swap_extension(&fb.filename, fb.format.extension());
}
KeyCode::Backspace => {
fb.filename.pop();
}
KeyCode::Enter => {
if fb.filename.trim().is_empty() {
toast = Some("Enter a file name".into());
} else {
result = Some(PendingCommand::Save {
path: fb.save_path(),
format: fb.format,
});
}
}
KeyCode::Char(c) => fb.filename.push(c),
_ => {}
}
} else {
match key.code {
KeyCode::Esc => close = true,
KeyCode::Down | KeyCode::Char('j') => fb.move_selection(1),
KeyCode::Up | KeyCode::Char('k') => fb.move_selection(-1),
KeyCode::Left | KeyCode::Char('h') | KeyCode::Backspace => fb.go_parent(),
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
if let Some(path) = fb.activate() {
result = Some(PendingCommand::Load { path });
}
}
_ => {}
}
}
new_dir = fb.cwd.clone();
}
self.browse_dir = Some(new_dir);
if let Some(t) = toast {
self.toast = Some(t);
}
if close || result.is_some() {
self.file_browser = None;
}
if let Some(cmd) = result {
self.pending_command = Some(cmd);
}
}
pub fn prompt_load(&mut self) {
self.open_file_browser();
}
fn handle_io_modal_key(&mut self, key: KeyEvent) {
let Some(m) = self.io_modal.as_mut() else {
return;
};
match key.code {
KeyCode::Esc => self.io_modal = None,
KeyCode::Backspace => {
m.input.pop();
}
KeyCode::Enter => {
let raw = m.input.trim().to_string();
self.io_modal = None;
if raw.is_empty() {
self.toast = Some("(cancelled — empty input)".into());
return;
}
let norm = raw.replace(':', " ");
let mut it = norm.split_whitespace();
let host = it.next().unwrap_or("localhost").to_string();
let snapshot_port = it.next().and_then(|s| s.parse().ok()).unwrap_or(2597);
let stream_port = it.next().and_then(|s| s.parse().ok()).unwrap_or(2598);
self.pending_command = Some(PendingCommand::Connect {
host,
snapshot_port,
stream_port,
});
}
KeyCode::Char(c) => {
m.input.push(c);
}
_ => {}
}
}
pub fn reset_views(&mut self) {
self.screen = Screen::Devices;
self.devices_state.select(Some(0));
self.detail_state.select(Some(0));
self.time_state.select(Some(0));
self.time_offset = 0;
self.nmea0183_state.select(Some(0));
self.pgn_top_state.select(Some(0));
self.filter_pgns = None;
self.filter_srcs = None;
self.search_query = None;
self.menu.open = None;
self.text_prompt = None;
self.src_select = None;
}
fn open_filter_pgns_prompt(&mut self) {
let buffer = self
.filter_pgns
.as_ref()
.map(|list| {
list.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(",")
})
.unwrap_or_default();
self.text_prompt = Some(TextPrompt {
kind: TextPromptKind::FilterPgns,
buffer,
});
}
fn open_src_select(&mut self, state: &AppState) {
let devs = state.device_list();
let sources: Vec<(u8, String)> = devs
.iter()
.map(|d| {
let label = if d.manufacturer.is_empty() && d.model.is_empty() {
format!("src {}", d.src)
} else {
format!(
"src {:3} {} {}",
d.src,
d.manufacturer.as_str(),
d.model.as_str()
)
};
(d.src, label)
})
.collect();
let selected = self
.filter_srcs
.as_ref()
.cloned()
.map(|v| v.into_iter().collect())
.unwrap_or_else(|| sources.iter().map(|(s, _)| *s).collect());
self.src_select = Some(SrcSelect {
sources,
selected,
cursor: 0,
});
}
fn drill_into_time_selection(&mut self, state: &AppState) {
let visible = self.visible_history_indices(state);
if let Some(target) = self
.time_state
.selected()
.and_then(|i| visible.get(i).copied())
&& let Some(row) = state.history.get(target)
{
let key = (row.pgn, row.src, row.secondary.clone());
if let Some(entry) = state.entries.get(&key) {
let history_pos = entry
.history_indices
.iter()
.position(|i| *i == target)
.unwrap_or(entry.history_indices.len().saturating_sub(1));
self.entry_scroll = 0;
self.screen = Screen::EntryDetail {
src: row.src,
pgn: row.pgn,
secondary: row.secondary.clone(),
history_pos,
return_to: Box::new(Screen::TimeView),
};
}
}
}
fn toggle_nmea0183_selection(
&mut self,
state: &AppState,
writer: &crate::cli::tui::client::Writer,
) {
let rows = state.nmea0183_rows();
if let Some(row) = self.nmea0183_state.selected().and_then(|i| rows.get(i)) {
let sentence = row
.sentence
.as_deref()
.unwrap_or(crate::cli::tui::state::NMEA0183_ALL);
let line = iso::nmea0183_filter_set(row.src, sentence, !row.muted);
if writer.send(line) {
let what = row.sentence.clone().unwrap_or_else(|| "all".into());
self.toast = Some(format!(
"{} {what} on src {}",
if row.muted { "Unmuted" } else { "Muted" },
row.src
));
} else {
self.toast = Some("Writer channel closed".into());
}
}
}
pub fn handle_key(
&mut self,
key: KeyEvent,
state: &AppState,
writer: &crate::cli::tui::client::Writer,
) {
if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c')) {
self.should_quit = true;
return;
}
if self.splash.is_some() {
self.splash = None;
return;
}
if has_unacknowledged_error(self, state) {
self.last_acknowledged_error = state.status.last_error.clone();
return;
}
if !self.connecting_dismissed {
self.connecting_dismissed = true;
return;
}
if self.help_visible {
self.help_visible = false;
return;
}
if self.menu.is_open() {
self.handle_menu_key(key, state, writer);
return;
}
if self.file_browser.is_some() {
self.handle_file_browser_key(key);
return;
}
if self.io_modal.is_some() {
self.handle_io_modal_key(key);
return;
}
if self.modal.is_some() {
self.handle_modal_key(key, writer);
return;
}
if self.text_prompt.is_some() {
self.handle_text_prompt_key(key, state);
return;
}
if self.src_select.is_some() {
self.handle_src_select_key(key);
return;
}
if matches!(key.code, KeyCode::F(10)) {
let menus = self.build_menus(state);
self.menu.open = Some(0);
self.menu.item = menus[0].first_item();
return;
}
if key.modifiers.contains(KeyModifiers::ALT)
&& let KeyCode::Char(c) = key.code
{
let menus = self.build_menus(state);
if let Some(i) = menus.iter().position(|m| m.hotkey.eq_ignore_ascii_case(&c)) {
self.menu.open = Some(i);
self.menu.item = menus[i].first_item();
}
return;
}
if matches!(key.code, KeyCode::F(1)) {
self.help_visible = true;
return;
}
self.toast = None;
if let Screen::EntryDetail { history_pos, .. } = &mut self.screen {
match key.code {
KeyCode::Left | KeyCode::Char('H') => {
*history_pos = history_pos.saturating_sub(1);
self.entry_scroll = 0;
return;
}
KeyCode::Right | KeyCode::Char('L') => {
*history_pos = history_pos.saturating_add(1);
self.entry_scroll = 0;
return;
}
_ => {}
}
}
match (&self.screen, key.code) {
(_, KeyCode::Char('q')) => self.should_quit = true,
(_, KeyCode::Char('t')) => self.screen = Screen::TimeView,
(_, KeyCode::Char('p')) => {
self.pgn_top_state.select(Some(0));
self.screen = Screen::PgnTop;
}
(Screen::PgnTop, KeyCode::Down) | (Screen::PgnTop, KeyCode::Char('j')) => {
navigate_list(&mut self.pgn_top_state, state.pgn_load_rows().len(), 1);
}
(Screen::PgnTop, KeyCode::Up) | (Screen::PgnTop, KeyCode::Char('k')) => {
navigate_list(&mut self.pgn_top_state, state.pgn_load_rows().len(), -1);
}
(Screen::PgnTop, KeyCode::Enter) => {
let rows = state.pgn_load_rows();
if let Some(r) = self.pgn_top_state.selected().and_then(|i| rows.get(i)) {
self.filter_pgns = Some(vec![r.pgn]);
self.time_state.select(Some(0));
self.screen = Screen::TimeView;
}
}
(Screen::PgnTop, KeyCode::Char('d')) => self.screen = Screen::Devices,
(Screen::PgnTop, KeyCode::Esc)
| (Screen::PgnTop, KeyCode::Backspace)
| (Screen::PgnTop, KeyCode::Char('h')) => {
self.screen = Screen::Devices;
}
(Screen::TimeView, KeyCode::Char('d')) => self.screen = Screen::Devices,
(Screen::TimeView, KeyCode::Down) | (Screen::TimeView, KeyCode::Char('j')) => {
let n = self.visible_history_len(state);
navigate_list(&mut self.time_state, n, 1);
}
(Screen::TimeView, KeyCode::Up) | (Screen::TimeView, KeyCode::Char('k')) => {
let n = self.visible_history_len(state);
navigate_list(&mut self.time_state, n, -1);
}
(Screen::TimeView, KeyCode::Char('/')) => {
self.text_prompt = Some(TextPrompt {
kind: TextPromptKind::Search,
buffer: self.search_query.clone().unwrap_or_default(),
});
}
(Screen::TimeView, KeyCode::Char('f')) => {
let buffer = self
.filter_pgns
.as_ref()
.map(|list| {
list.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(",")
})
.unwrap_or_default();
self.text_prompt = Some(TextPrompt {
kind: TextPromptKind::FilterPgns,
buffer,
});
}
(Screen::TimeView, KeyCode::Char('s')) => {
let devs = state.device_list();
let sources: Vec<(u8, String)> = devs
.iter()
.map(|d| {
let label = if d.manufacturer.is_empty() && d.model.is_empty() {
format!("src {}", d.src)
} else {
format!(
"src {:3} {} {}",
d.src,
d.manufacturer.as_str(),
d.model.as_str()
)
};
(d.src, label)
})
.collect();
let selected = self
.filter_srcs
.as_ref()
.cloned()
.map(|v| v.into_iter().collect())
.unwrap_or_else(|| sources.iter().map(|(s, _)| *s).collect());
self.src_select = Some(SrcSelect {
sources,
selected,
cursor: 0,
});
}
(Screen::TimeView, KeyCode::Char('n')) => {
self.jump_search(state, 1);
}
(Screen::TimeView, KeyCode::Char('N')) => {
self.jump_search(state, -1);
}
(Screen::TimeView, KeyCode::Enter) => {
let visible = self.visible_history_indices(state);
if let Some(target) = self
.time_state
.selected()
.and_then(|i| visible.get(i).copied())
&& let Some(row) = state.history.get(target)
{
let key = (row.pgn, row.src, row.secondary.clone());
if let Some(entry) = state.entries.get(&key) {
let history_pos = entry
.history_indices
.iter()
.position(|i| *i == target)
.unwrap_or(entry.history_indices.len().saturating_sub(1));
self.entry_scroll = 0;
self.screen = Screen::EntryDetail {
src: row.src,
pgn: row.pgn,
secondary: row.secondary.clone(),
history_pos,
return_to: Box::new(Screen::TimeView),
};
}
}
}
(Screen::TimeView, KeyCode::Esc) => self.screen = Screen::Devices,
(Screen::Devices, KeyCode::Down) | (Screen::Devices, KeyCode::Char('j')) => {
navigate_list(&mut self.devices_state, state.device_list().len(), 1);
}
(Screen::Devices, KeyCode::Up) | (Screen::Devices, KeyCode::Char('k')) => {
navigate_list(&mut self.devices_state, state.device_list().len(), -1);
}
(Screen::Devices, KeyCode::Enter) => {
let devs = state.device_list();
if let Some(d) = self.devices_state.selected().and_then(|i| devs.get(i)) {
self.screen = Screen::DeviceDetail { src: d.src };
self.detail_state.select(Some(0));
}
}
(Screen::Devices, KeyCode::Char('n')) if state.status.mode == Mode::Live => {
self.nmea0183_state.select(Some(0));
self.screen = Screen::Nmea0183;
}
(Screen::Devices, KeyCode::Char('o')) if state.status.mode == Mode::Live => {
self.overrides_state.select(Some(0));
self.screen = Screen::Overrides;
}
(Screen::Overrides, KeyCode::Down) | (Screen::Overrides, KeyCode::Char('j')) => {
navigate_list(&mut self.overrides_state, state.override_rows().len(), 1);
}
(Screen::Overrides, KeyCode::Up) | (Screen::Overrides, KeyCode::Char('k')) => {
navigate_list(&mut self.overrides_state, state.override_rows().len(), -1);
}
(Screen::Overrides, KeyCode::Enter) | (Screen::Overrides, KeyCode::Char('e')) => {
self.open_selected_override_modal(state);
}
(Screen::Overrides, KeyCode::Char('d')) | (Screen::Overrides, KeyCode::Delete) => {
self.delete_selected_override(state, writer);
}
(Screen::Overrides, KeyCode::Esc)
| (Screen::Overrides, KeyCode::Backspace)
| (Screen::Overrides, KeyCode::Char('h')) => {
self.screen = Screen::Devices;
}
(Screen::Nmea0183, KeyCode::Down) | (Screen::Nmea0183, KeyCode::Char('j')) => {
navigate_list(&mut self.nmea0183_state, state.nmea0183_rows().len(), 1);
}
(Screen::Nmea0183, KeyCode::Up) | (Screen::Nmea0183, KeyCode::Char('k')) => {
navigate_list(&mut self.nmea0183_state, state.nmea0183_rows().len(), -1);
}
(Screen::Nmea0183, KeyCode::Char(' ')) | (Screen::Nmea0183, KeyCode::Enter) => {
let rows = state.nmea0183_rows();
if let Some(row) = self.nmea0183_state.selected().and_then(|i| rows.get(i)) {
let sentence = row
.sentence
.as_deref()
.unwrap_or(crate::cli::tui::state::NMEA0183_ALL);
let line = iso::nmea0183_filter_set(row.src, sentence, !row.muted);
if writer.send(line) {
let what = row.sentence.clone().unwrap_or_else(|| "all".into());
self.toast = Some(format!(
"{} {what} on src {}",
if row.muted { "Unmuted" } else { "Muted" },
row.src
));
} else {
self.toast = Some("Writer channel closed".into());
}
}
}
(Screen::Nmea0183, KeyCode::Esc)
| (Screen::Nmea0183, KeyCode::Backspace)
| (Screen::Nmea0183, KeyCode::Char('h'))
| (Screen::Nmea0183, KeyCode::Char('d')) => {
self.screen = Screen::Devices;
}
(Screen::DeviceDetail { src }, KeyCode::Down)
| (Screen::DeviceDetail { src }, KeyCode::Char('j')) => {
let n = self.detail_rows(*src, state).len();
navigate_list(&mut self.detail_state, n, 1);
}
(Screen::DeviceDetail { src }, KeyCode::Up)
| (Screen::DeviceDetail { src }, KeyCode::Char('k')) => {
let n = self.detail_rows(*src, state).len();
navigate_list(&mut self.detail_state, n, -1);
}
(Screen::DeviceDetail { src }, KeyCode::Enter) => {
let rows = self.detail_rows(*src, state);
if let Some(row) = self.detail_state.selected().and_then(|i| rows.get(i)) {
if row.count == 0 {
return;
}
self.entry_scroll = 0;
let history_pos = row.history_indices.len().saturating_sub(1);
let return_src = *src;
self.screen = Screen::EntryDetail {
src: return_src,
pgn: row.pgn,
secondary: row.secondary.clone(),
history_pos,
return_to: Box::new(Screen::DeviceDetail { src: return_src }),
};
}
}
(Screen::DeviceDetail { src }, KeyCode::Char('i'))
if state.status.mode == Mode::Live =>
{
let line = iso::iso_request(*src, 126464);
if writer.send(line) {
self.toast = Some(format!("Sent ISO Request 126464 → src {src}"));
} else {
self.toast = Some("Writer channel closed".into());
}
}
(Screen::DeviceDetail { src }, KeyCode::Char('o'))
if state.status.mode == Mode::Live =>
{
self.open_override_modal(*src, state);
}
(Screen::DeviceDetail { .. }, KeyCode::Esc)
| (Screen::DeviceDetail { .. }, KeyCode::Backspace)
| (Screen::DeviceDetail { .. }, KeyCode::Char('h')) => {
self.screen = Screen::Devices;
}
(Screen::EntryDetail { .. }, KeyCode::Down)
| (Screen::EntryDetail { .. }, KeyCode::Char('j')) => {
self.entry_scroll = self.entry_scroll.saturating_add(1);
}
(Screen::EntryDetail { .. }, KeyCode::Up)
| (Screen::EntryDetail { .. }, KeyCode::Char('k')) => {
self.entry_scroll = self.entry_scroll.saturating_sub(1);
}
(Screen::EntryDetail { .. }, KeyCode::PageDown)
| (Screen::EntryDetail { .. }, KeyCode::Char(' ')) => {
self.entry_scroll = self.entry_scroll.saturating_add(10);
}
(Screen::EntryDetail { .. }, KeyCode::PageUp) => {
self.entry_scroll = self.entry_scroll.saturating_sub(10);
}
(Screen::EntryDetail { .. }, KeyCode::Home)
| (Screen::EntryDetail { .. }, KeyCode::Char('g')) => {
self.entry_scroll = 0;
}
(Screen::EntryDetail { .. }, KeyCode::End)
| (Screen::EntryDetail { .. }, KeyCode::Char('G')) => {
self.entry_scroll = u16::MAX;
}
(Screen::EntryDetail { return_to, .. }, KeyCode::Esc)
| (Screen::EntryDetail { return_to, .. }, KeyCode::Backspace)
| (Screen::EntryDetail { return_to, .. }, KeyCode::Char('h')) => {
self.screen = (**return_to).clone();
}
_ => {}
}
}
fn handle_text_prompt_key(&mut self, key: KeyEvent, state: &AppState) {
let Some(prompt) = self.text_prompt.as_mut() else {
return;
};
match key.code {
KeyCode::Esc => {
self.text_prompt = None;
}
KeyCode::Backspace => {
prompt.buffer.pop();
}
KeyCode::Enter => {
let raw = prompt.buffer.trim().to_string();
let kind = prompt.kind;
self.text_prompt = None;
match kind {
TextPromptKind::Search => {
if raw.is_empty() {
self.search_query = None;
} else {
self.search_query = Some(raw);
self.jump_search(state, 0);
}
}
TextPromptKind::FilterPgns => {
if raw.is_empty() {
self.filter_pgns = None;
} else {
let list: Vec<u32> = raw
.split(|c: char| c == ',' || c.is_whitespace())
.filter(|t| !t.is_empty())
.filter_map(|t| t.parse().ok())
.collect();
self.filter_pgns = (!list.is_empty()).then_some(list);
self.time_state.select(Some(0));
}
}
}
}
KeyCode::Char(c) => {
prompt.buffer.push(c);
}
_ => {}
}
}
fn handle_src_select_key(&mut self, key: KeyEvent) {
let Some(sel) = self.src_select.as_mut() else {
return;
};
let len = sel.sources.len();
match key.code {
KeyCode::Esc => {
self.src_select = None;
}
KeyCode::Enter => {
let selected = sel.selected.clone();
let full = sel.sources.iter().all(|(s, _)| selected.contains(s));
self.src_select = None;
self.filter_srcs = if full || selected.is_empty() {
None
} else {
let mut v: Vec<u8> = selected.into_iter().collect();
v.sort_unstable();
Some(v)
};
self.time_state.select(Some(0));
}
KeyCode::Down | KeyCode::Char('j') => {
if len > 0 {
sel.cursor = (sel.cursor + 1) % len;
}
}
KeyCode::Up | KeyCode::Char('k') => {
if len > 0 {
sel.cursor = (sel.cursor + len - 1) % len;
}
}
KeyCode::Char(' ') => {
if let Some((src, _)) = sel.sources.get(sel.cursor)
&& !sel.selected.insert(*src)
{
sel.selected.remove(src);
}
}
KeyCode::Char('a') | KeyCode::Char('A') => {
if sel.selected.len() == sel.sources.len() {
sel.selected.clear();
} else {
sel.selected = sel.sources.iter().map(|(s, _)| *s).collect();
}
}
_ => {}
}
}
fn jump_search(&mut self, state: &AppState, dir: i32) {
let Some(q_lower) = self.search_query.as_ref().map(|s| s.to_lowercase()) else {
return;
};
let visible = self.visible_history_indices(state);
if visible.is_empty() {
return;
}
let cur = self.time_state.selected().unwrap_or(0);
let matches: Vec<usize> = visible
.iter()
.enumerate()
.filter(|(_, hi)| {
state
.history
.get(**hi)
.is_some_and(|h| row_search_string(h).to_lowercase().contains(&q_lower))
})
.map(|(vpos, _)| vpos)
.collect();
if matches.is_empty() {
self.toast = Some(format!(
"no matches for \"{}\"",
self.search_query.as_deref().unwrap_or("")
));
return;
}
let target = match dir.cmp(&0) {
std::cmp::Ordering::Equal => matches
.iter()
.copied()
.find(|m| *m >= cur)
.unwrap_or(matches[0]),
std::cmp::Ordering::Greater => matches
.iter()
.copied()
.find(|m| *m > cur)
.unwrap_or(matches[0]),
std::cmp::Ordering::Less => matches
.iter()
.copied()
.rev()
.find(|m| *m < cur)
.unwrap_or(*matches.last().unwrap()),
};
self.time_state.select(Some(target));
}
fn handle_modal_key(&mut self, key: KeyEvent, writer: &crate::cli::tui::client::Writer) {
let Some(modal) = self.modal.as_mut() else {
return;
};
match key.code {
KeyCode::Esc => {
self.modal = None;
}
KeyCode::Backspace => {
modal.input.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() => {
modal.input.push(c);
}
KeyCode::Enter => {
let interval_ms: u32 = match modal.input.parse() {
Ok(v) => v,
Err(_) => {
self.toast = Some("Enter an integer in milliseconds".into());
return;
}
};
let line = iso::override_set(
modal.src,
modal.pgn,
interval_ms,
modal.manufacturer_code,
modal.industry_code,
);
if writer.send(line) {
self.toast = Some(format!(
"Sent override → src {} pgn {} = {} ms",
modal.src, modal.pgn, interval_ms
));
} else {
self.toast = Some("Writer channel closed".into());
}
self.modal = None;
}
_ => {}
}
}
}
fn proprietary_codes(entry: &Entry) -> (Option<u16>, Option<u8>) {
if !is_proprietary_pgn(entry.pgn) {
return (None, None);
}
let mfr = entry
.line
.pointer("/fields/Manufacturer Code")
.and_then(|v| {
v.as_i64()
.or_else(|| v.pointer("/value").and_then(|n| n.as_i64()))
})
.and_then(|n| u16::try_from(n).ok());
let ind = entry
.line
.pointer("/fields/Industry Code")
.and_then(|v| {
v.as_i64()
.or_else(|| v.pointer("/value").and_then(|n| n.as_i64()))
})
.and_then(|n| u8::try_from(n).ok());
(mfr, ind)
}
fn is_proprietary_pgn(pgn: u32) -> bool {
matches!(pgn, 0xEF00..=0xEFFF | 0xFF00..=0xFFFF | 0x1EF00..=0x1EFFF | 0x1FF00..=0x1FFFF)
}
fn navigate_list(state: &mut ListState, len: usize, delta: i32) {
if len == 0 {
state.select(None);
return;
}
let cur = state.selected().unwrap_or(0) as i32;
let next = (cur + delta).rem_euclid(len as i32) as usize;
state.select(Some(next));
}
pub type Tty = Terminal<CrosstermBackend<Stdout>>;
pub fn draw(tty: &mut Tty, app: &mut App, state: &AppState) -> Result<()> {
if let Screen::EntryDetail {
src,
pgn,
secondary,
history_pos,
..
} = &mut app.screen
{
let key = (*pgn, *src, secondary.clone());
if let Some(entry) = state.entries.get(&key) {
let max_pos = entry.history_indices.len().saturating_sub(1);
if *history_pos > max_pos {
*history_pos = max_pos;
}
let line = entry
.history_indices
.get(*history_pos)
.and_then(|idx| state.history.get(*idx))
.map(|h| &h.line)
.unwrap_or(&entry.line);
let pretty = serde_json::to_string_pretty(line).unwrap_or_else(|_| line.to_string());
let lines = pretty.lines().count() as u16;
let max_scroll = lines.saturating_sub(1);
if app.entry_scroll > max_scroll {
app.entry_scroll = max_scroll;
}
}
}
if !app.connecting_dismissed
&& (state.status.mode == Mode::Log
|| (state.status.snapshot_loaded
&& state.status.stream_connected
&& state.status.last_error.is_none()))
{
app.connecting_dismissed = true;
}
tty.draw(|f| render(f, app, state))?;
Ok(())
}
fn render(f: &mut ratatui::Frame<'_>, app: &mut App, state: &AppState) {
if let Some(frame) = app.splash {
let area = f.area();
let verb = if state.status.mode == Mode::Live {
"CONNECTING"
} else {
"LOADING"
};
draw_splash(f, area, frame, verb);
app.splash = (frame + 1 < SPLASH_FRAMES).then_some(frame + 1);
return;
}
{
let area = f.area();
f.render_widget(
Block::default().style(Style::default().bg(DESKTOP_BG)),
area,
);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(area);
let menus = app.build_menus(state);
menu::draw_bar(f, chunks[0], &menus, &app.menu);
draw_info_bar(f, chunks[1], state);
match &app.screen {
Screen::Devices => draw_devices(f, chunks[2], app, state),
Screen::DeviceDetail { src } => draw_device_detail(f, chunks[2], app, state, *src),
Screen::EntryDetail {
src,
pgn,
secondary,
history_pos,
..
} => {
draw_entry_detail(
f,
chunks[2],
state,
*src,
*pgn,
secondary.as_deref(),
*history_pos,
app.entry_scroll,
);
}
Screen::TimeView => draw_time_view(f, chunks[2], app, state),
Screen::PgnTop => draw_pgn_top(f, chunks[2], app, state),
Screen::Nmea0183 => draw_nmea0183(f, chunks[2], app, state),
Screen::Overrides => draw_overrides(f, chunks[2], app, state),
}
if let Some(prompt) = &app.text_prompt {
draw_text_prompt(f, chunks[3], prompt);
} else {
draw_bottom_bar(f, chunks[3], app, state);
}
menu::draw_dropdown(f, chunks[0], area, &menus, &app.menu);
if let Some(modal) = &app.modal {
draw_modal(f, area, modal);
}
if let Some(io) = &app.io_modal {
draw_io_modal(f, area, io);
}
if let Some(fb) = &app.file_browser {
draw_file_browser(f, area, fb);
}
if let Some(sel) = &app.src_select {
draw_src_select_modal(f, area, sel);
}
if app.help_visible {
draw_help_modal(f, area);
}
if let Some(p) = &state.save_progress {
draw_progress_modal(f, area, p);
}
if !app.connecting_dismissed {
draw_connecting_modal(f, area, state);
}
if has_unacknowledged_error(app, state)
&& let Some(err) = state.status.last_error.as_deref()
{
draw_error_modal(f, area, err);
}
}
}
fn has_unacknowledged_error(app: &App, state: &AppState) -> bool {
match (
state.status.last_error.as_deref(),
app.last_acknowledged_error.as_deref(),
) {
(Some(cur), Some(ack)) => cur != ack,
(Some(_), None) => true,
_ => false,
}
}
fn draw_info_bar(f: &mut ratatui::Frame<'_>, area: Rect, state: &AppState) {
let s = &state.status;
let text = match s.mode {
Mode::Live => {
let conn = if s.stream_connected { "live" } else { "disc" };
let snap = if s.snapshot_loaded { "ok" } else { "…" };
format!(
" endpoint {host}:{snap_port}/{stream_port} snap:{snap} stream:{conn} msgs:{msgs} devs:{devs} entries:{entries}",
host = s.host,
snap_port = s.snapshot_port,
stream_port = s.stream_port,
msgs = s.messages_seen,
devs = state.device_list().len(),
entries = state.entries.len(),
)
}
Mode::Log => {
let load = if s.snapshot_loaded {
"loaded"
} else {
"loading…"
};
format!(
" log: {host} {load} msgs:{msgs} devs:{devs} entries:{entries}",
host = s.host,
msgs = s.messages_seen,
devs = state.device_list().len(),
entries = state.entries.len(),
)
}
};
let p = Paragraph::new(text).style(Style::default().bg(DESKTOP_BG).fg(Color::White));
f.render_widget(p, area);
}
fn draw_bottom_bar(f: &mut ratatui::Frame<'_>, area: Rect, app: &App, state: &AppState) {
let surface = Style::default().bg(SURFACE_BG).fg(SURFACE_FG);
f.render_widget(Block::default().style(surface), area);
let line: Line<'static> = match (&app.toast, &state.status.last_error) {
(Some(t), _) => Line::from(Span::styled(format!(" {t}"), surface)),
(_, Some(e)) => Line::from(vec![
Span::styled(
" error: ",
surface.fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(e.clone(), surface.fg(Color::Red)),
]),
_ => Line::from(Span::styled(
format!(" {}", screen_hint(&app.screen, state.status.mode)),
surface,
)),
};
f.render_widget(Paragraph::new(line).style(surface), area);
}
fn screen_hint(screen: &Screen, mode: Mode) -> &'static str {
match (screen, mode) {
(Screen::Devices, Mode::Live) => {
"F10 menu | ↑↓ move | Enter open | n NMEA0183 | o overrides | t timeline | p PGN load | q quit"
}
(Screen::Devices, _) => {
"F10 menu | ↑↓ move | Enter open | t timeline | p PGN load | q quit"
}
(Screen::Nmea0183, _) => {
"F10 menu | ↑↓ move | Space toggle mute | d/Esc back | (row = whole source or one sentence)"
}
(Screen::Overrides, _) => {
"F10 menu | ↑↓ move | e/Enter change interval | d/Del delete | Esc back"
}
(Screen::PgnTop, _) => {
"F10 menu | ↑↓ move | Enter timeline for PGN | t timeline | d/Esc back | q quit"
}
(Screen::DeviceDetail { .. }, Mode::Log) => {
"F10 menu | ↑↓ move | Enter open entry | Esc back | t timeline | p PGN load"
}
(Screen::DeviceDetail { .. }, Mode::Live) => {
"F10 menu | ↑↓ move | Enter open | Esc back | i ISO 126464 | o override | p PGN load"
}
(Screen::EntryDetail { .. }, _) => {
"F10 menu | ↑↓ scroll 1 | ←→ prev/next instance | PgUp/PgDn scroll 10 | Esc back"
}
(Screen::TimeView, _) => {
"F10 menu | ↑↓ move | Enter drill in | / search | n/N next/prev | f filter pgns | s filter srcs | d devices | Esc back"
}
}
}
fn draw_devices(f: &mut ratatui::Frame<'_>, area: Rect, app: &mut App, state: &AppState) {
let devices = state.device_list();
let items: Vec<ListItem> = devices
.iter()
.map(|d| ListItem::new(format_device_row(d)))
.collect();
if !devices.is_empty() && app.devices_state.selected().is_none() {
app.devices_state.select(Some(0));
}
let list = List::new(items)
.block(panel_block(format!(" Devices ({} src) ", devices.len())))
.highlight_style(panel_highlight())
.highlight_symbol(" ▶ ")
.highlight_spacing(HighlightSpacing::Always);
f.render_stateful_widget(list, area, &mut app.devices_state);
}
fn format_device_row(d: &DeviceInfo) -> Line<'static> {
let mfr = if d.manufacturer.is_empty() {
"(unknown)".to_string()
} else {
d.manufacturer.clone()
};
Line::from(vec![
Span::styled(format!(" {:3} ", d.src), Style::default().fg(Color::Yellow)),
Span::raw(format!(" {:20.20}", mfr)),
Span::raw(format!(" {:24.24}", d.model)),
Span::raw(format!(" sw {:14.14}", d.software)),
Span::raw(format!(" pgns {:3}", d.pgn_count)),
Span::raw(format!(" {}", d.installation)),
])
}
fn draw_nmea0183(f: &mut ratatui::Frame<'_>, area: Rect, app: &mut App, state: &AppState) {
let rows = state.nmea0183_rows();
let labels: std::collections::HashMap<u8, String> = state
.device_list()
.into_iter()
.map(|d| {
let mfr = if d.manufacturer.is_empty() {
"(unknown)".to_string()
} else {
d.manufacturer
};
let model = if d.model.is_empty() {
String::new()
} else {
format!(" {}", d.model)
};
(d.src, format!("{mfr}{model}"))
})
.collect();
let items: Vec<ListItem> = rows
.iter()
.map(|r| {
let (tag, color) = if r.muted {
("muted", Color::Red)
} else {
(" on ", Color::Green)
};
let line = match &r.sentence {
None => Line::from(vec![
Span::styled(
format!(" src {:3} ", r.src),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("[{tag}] "),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(
labels.get(&r.src).cloned().unwrap_or_default(),
Style::default().add_modifier(Modifier::BOLD),
),
]),
Some(s) => Line::from(vec![
Span::raw(" "),
Span::raw(format!("{s:<5}")),
Span::styled(format!("[{tag}]"), Style::default().fg(color)),
]),
};
ListItem::new(line)
})
.collect();
if !rows.is_empty() && app.nmea0183_state.selected().is_none() {
app.nmea0183_state.select(Some(0));
}
let title = if rows.is_empty() {
" NMEA 0183 filter (waiting for pipeline report…) ".to_string()
} else {
format!(" NMEA 0183 filter — {} sources ", state.nmea0183.len())
};
let list = List::new(items)
.block(panel_block(title))
.highlight_style(panel_highlight())
.highlight_symbol(" ▶ ")
.highlight_spacing(HighlightSpacing::Always);
f.render_stateful_widget(list, area, &mut app.nmea0183_state);
}
fn draw_overrides(f: &mut ratatui::Frame<'_>, area: Rect, app: &mut App, state: &AppState) {
let rows = state.override_rows();
let items: Vec<ListItem> = rows
.iter()
.map(|ov| {
let interval = if ov.interval_ms == INTERVAL_OFF {
" OFF".to_string()
} else {
format!("{:>4} ms", ov.interval_ms)
};
let color = if ov.interval_ms == INTERVAL_OFF {
Color::Red
} else {
Color::Green
};
let line = Line::from(vec![
Span::styled(
format!(" src {:3} ", ov.src),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:6} ", ov.pgn),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(format!("{:<9}", interval), Style::default().fg(color)),
Span::raw(format!(" {}", ov.description)),
]);
ListItem::new(line)
})
.collect();
if !rows.is_empty() && app.overrides_state.selected().is_none() {
app.overrides_state.select(Some(0));
}
let title = if rows.is_empty() {
" PGN Overrides (none active) ".to_string()
} else {
format!(" PGN Overrides — {} active ", rows.len())
};
let list = List::new(items)
.block(panel_block(title))
.highlight_style(panel_highlight())
.highlight_symbol(" ▶ ")
.highlight_spacing(HighlightSpacing::Always);
f.render_stateful_widget(list, area, &mut app.overrides_state);
}
fn draw_device_detail(
f: &mut ratatui::Frame<'_>,
area: Rect,
app: &mut App,
state: &AppState,
src: u8,
) {
let lists = state.pgn_lists_for_src(src);
let bottom_h = if lists.is_empty() { 0 } else { 4 };
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(bottom_h)])
.split(area);
let rows = app.detail_rows(src, state);
let items: Vec<ListItem> = rows
.iter()
.map(|e| ListItem::new(format_entry_row(e, state)))
.collect();
let title = match state.device_list().iter().find(|d| d.src == src) {
Some(d) => format!(
" src {} — {} {} ({} entries) ",
src,
d.manufacturer,
d.model,
rows.len(),
),
None => format!(" src {} ({} entries) ", src, rows.len()),
};
if !rows.is_empty() && app.detail_state.selected().is_none() {
app.detail_state.select(Some(0));
}
let list = List::new(items)
.block(panel_block(title))
.highlight_style(panel_highlight())
.highlight_symbol(" ▶ ")
.highlight_spacing(HighlightSpacing::Always);
f.render_stateful_widget(list, chunks[0], &mut app.detail_state);
if bottom_h > 0 {
draw_pgn_lists(f, chunks[1], &lists);
}
}
fn robust_interval(e: &Entry, state: &AppState) -> Option<std::time::Duration> {
const WINDOW: usize = 64;
let mut stamps: Vec<i64> = e
.history_indices
.iter()
.rev()
.take(WINDOW + 1)
.filter_map(|&i| state.history.get(i))
.filter_map(|h| h.timestamp.as_deref())
.filter_map(crate::engine::parse_iso_ms)
.collect();
if stamps.len() < 2 {
return e.interval();
}
stamps.reverse();
let mut deltas: Vec<i64> = stamps
.windows(2)
.map(|w| w[1] - w[0])
.filter(|&d| d > 0)
.collect();
if deltas.is_empty() {
return e.interval();
}
deltas.sort_unstable();
let mid = deltas.len() / 2;
let median = if deltas.len() % 2 == 1 {
deltas[mid]
} else {
(deltas[mid - 1] + deltas[mid]) / 2
};
Some(std::time::Duration::from_millis(median as u64))
}
fn pgn_on_request(pgn: u32) -> bool {
crate::engine::PgnDatabase::embedded(crate::engine::Units::Metric)
.first_pgn(pgn)
.and_then(|p| p.transmission_irregular)
.unwrap_or(false)
}
fn format_entry_row(e: &Entry, state: &AppState) -> Line<'static> {
let mode = state.status.mode;
if e.count == 0 {
let silenced = state
.overrides
.get(&(e.src, e.pgn))
.is_some_and(|o| o.interval_ms == INTERVAL_OFF);
let advertised = !silenced;
let fallback = if advertised {
"(advertised)"
} else {
"(disabled)"
};
let description = if e.description.is_empty() {
fallback
} else {
e.description.as_str()
};
let dim = Style::default().fg(Color::DarkGray);
let (state_label, state_style, note) = if advertised {
("—", dim, "(advertised)")
} else {
("OFF", Style::default().fg(Color::Red), "(silenced)")
};
return Line::from(vec![
Span::styled(format!(" {:6} ", e.pgn), Style::default().fg(Color::Yellow)),
Span::styled(format!("{:14.14}", ""), dim),
Span::styled(format!(" {description:30.30}"), dim),
Span::styled(format!(" {state_label:>13}"), state_style),
Span::styled(format!(" {:>10}", ""), dim),
Span::styled(format!(" {note:>13}"), dim),
]);
}
let sec = e
.secondary
.as_deref()
.map(|s| format!(":{s}"))
.unwrap_or_default();
let mut spans = vec![
Span::styled(format!(" {:6} ", e.pgn), Style::default().fg(Color::Cyan)),
Span::raw(format!("{:14.14}", sec)),
Span::raw(format!(" {:30.30}", e.description)),
Span::raw(if pgn_on_request(e.pgn) {
format!(" {:>13}", "on request")
} else {
format!(" every {}", format_interval(robust_interval(e, state)))
}),
];
if mode == Mode::Live {
let age = e.last_update.elapsed().as_secs();
spans.push(Span::raw(format!(" age {age:>4}s")));
}
spans.push(Span::raw(format!(" count {:>6}", e.count)));
Line::from(spans)
}
fn synthesize_silenced_entry(ov: &OverrideRow) -> Entry {
let now = std::time::Instant::now();
let line = if is_proprietary_pgn(ov.pgn) {
serde_json::json!({ "fields": {
"Manufacturer Code": ov.manufacturer_code,
"Industry Code": ov.industry_code,
}})
} else {
serde_json::Value::Null
};
Entry {
pgn: ov.pgn,
src: ov.src,
secondary: None,
description: ov.description.clone(),
line,
last_update: now,
count: 0,
first_seen: now,
first_stamp_ms: None,
last_stamp_ms: None,
history_indices: Vec::new(),
}
}
fn variant_manufacturer(v: &crate::engine::PgnInfo) -> Option<u16> {
v.fields
.first()
.and_then(|f| f.match_value)
.and_then(|m| u16::try_from(m).ok())
}
fn variant_industry(v: &crate::engine::PgnInfo) -> Option<u8> {
v.fields
.get(2)
.and_then(|f| f.match_value)
.and_then(|i| u8::try_from(i).ok())
}
fn advertised_variant(
pgn: u32,
device_mfr: Option<u16>,
) -> Option<&'static crate::engine::PgnInfo> {
let db = crate::engine::PgnDatabase::embedded(crate::engine::Units::Metric);
if !is_proprietary_pgn(pgn) {
return db.first_pgn(pgn);
}
let mfr = device_mfr?;
db.pgn_variants(pgn)
.find(|v| variant_manufacturer(v) == Some(mfr))
}
fn device_manufacturer(src: u8, state: &AppState) -> Option<u16> {
if let Some(k) = state.src_to_name.get(&src) {
return Some(k.manufacturer_code);
}
state
.entries_for_src(src)
.into_iter()
.find_map(|e| proprietary_codes(e).0)
}
fn synthesize_advertised_entry(variant: &crate::engine::PgnInfo, src: u8) -> Entry {
let now = std::time::Instant::now();
let line = if is_proprietary_pgn(variant.pgn) {
serde_json::json!({ "fields": {
"Manufacturer Code": variant_manufacturer(variant),
"Industry Code": variant_industry(variant),
}})
} else {
serde_json::json!({ "fields": {} })
};
Entry {
pgn: variant.pgn,
src,
secondary: None,
description: variant.description.to_string(),
line,
last_update: now,
count: 0,
first_seen: now,
first_stamp_ms: None,
last_stamp_ms: None,
history_indices: Vec::new(),
}
}
fn format_interval(d: Option<std::time::Duration>) -> String {
let Some(d) = d else {
return format!("{:>7}", "—");
};
let ms = d.as_millis() as u64;
let step = match ms {
0..=199 => 10,
200..=999 => 50,
1_000..=9_999 => 100,
_ => 1000,
};
let rounded = ((ms + step / 2) / step) * step;
if rounded < 10_000 {
format!("{rounded:>5}ms")
} else {
format!("{:>6.1}s", rounded as f32 / 1000.0)
}
}
fn draw_pgn_lists(
f: &mut ratatui::Frame<'_>,
area: Rect,
lists: &crate::cli::tui::state::PgnLists,
) {
let tx = lists
.tx
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(" ");
let rx = lists
.rx
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(" ");
let text = vec![
Line::from(vec![
Span::styled("TX: ", Style::default().fg(Color::Green)),
Span::raw(tx),
]),
Line::from(vec![
Span::styled("RX: ", Style::default().fg(Color::Magenta)),
Span::raw(rx),
]),
];
let p = Paragraph::new(text)
.block(panel_block(" PGN List (126464) "))
.wrap(Wrap { trim: true });
f.render_widget(p, area);
}
#[allow(clippy::too_many_arguments)]
fn draw_entry_detail(
f: &mut ratatui::Frame<'_>,
area: Rect,
state: &AppState,
src: u8,
pgn: u32,
secondary: Option<&str>,
history_pos: usize,
scroll: u16,
) {
let key = (pgn, src, secondary.map(|s| s.to_string()));
let Some(entry) = state.entries.get(&key) else {
let p = Paragraph::new("(entry no longer in cache)")
.style(Style::default().bg(PANEL_BG).fg(PANEL_FG))
.block(panel_block(format!(" PGN {pgn} src {src} ")));
f.render_widget(p, area);
return;
};
let (line, timestamp) = entry
.history_indices
.get(history_pos)
.and_then(|idx| state.history.get(*idx))
.map(|h| (&h.line, h.timestamp.as_deref()))
.unwrap_or((&entry.line, None));
let pretty = serde_json::to_string_pretty(line).unwrap_or_else(|_| line.to_string());
let total = entry.history_indices.len().max(1);
let position = format!("[{}/{total}]", history_pos + 1);
let stamp = timestamp.map(|t| format!(" {t}")).unwrap_or_default();
let age_suffix = if state.status.mode == Mode::Live {
format!(" age {}s", entry.last_update.elapsed().as_secs())
} else {
String::new()
};
let title = format!(
" PGN {} src {} {}{} {} count {}{age_suffix} ",
entry.pgn,
entry.src,
entry
.secondary
.as_deref()
.map(|s| format!("[{s}] "))
.unwrap_or_default(),
position,
stamp,
entry.count,
);
let p = Paragraph::new(pretty)
.style(Style::default().bg(PANEL_BG).fg(PANEL_FG))
.block(panel_block(title))
.wrap(Wrap { trim: false })
.scroll((scroll, 0));
f.render_widget(p, area);
}
fn draw_time_view(f: &mut ratatui::Frame<'_>, area: Rect, app: &mut App, state: &AppState) {
let total = app.visible_history_len(state);
if total == 0 {
app.time_state.select(None);
app.time_offset = 0;
} else {
let sel = app.time_state.selected().unwrap_or(0).min(total - 1);
app.time_state.select(Some(sel));
}
let view_h = area.height.saturating_sub(2) as usize; if view_h > 0 && total > 0 {
let sel = app.time_state.selected().unwrap_or(0);
if sel < app.time_offset {
app.time_offset = sel;
} else if sel >= app.time_offset + view_h {
app.time_offset = sel + 1 - view_h;
}
app.time_offset = app.time_offset.min(total.saturating_sub(view_h));
}
let window = app.visible_window(state, app.time_offset, view_h.max(1));
let items: Vec<ListItem> = window
.iter()
.filter_map(|hi| state.history.get(*hi).map(|h| (hi, h)))
.map(|(hi, h)| ListItem::new(format_time_row(*hi, h)))
.collect();
let mut title_parts = vec![format!("Timeline ({total}")];
if state.history.len() != total {
title_parts.push(format!(" of {}", state.history.len()));
}
title_parts.push(")".to_string());
if let Some(list) = &app.filter_pgns {
title_parts.push(format!(
" pgns: {}",
list.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(",")
));
}
if let Some(list) = &app.filter_srcs {
title_parts.push(format!(
" srcs: {}",
list.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(",")
));
}
if let Some(q) = &app.search_query {
title_parts.push(format!(" /{q}"));
}
let title: String = format!(" {} ", title_parts.concat());
let list_widget = List::new(items)
.block(panel_block(title))
.highlight_style(panel_highlight())
.highlight_symbol(" ▶ ")
.highlight_spacing(HighlightSpacing::Always);
let mut window_state = ListState::default();
if let Some(sel) = app.time_state.selected() {
window_state.select(Some(sel.saturating_sub(app.time_offset)));
}
f.render_stateful_widget(list_widget, area, &mut window_state);
}
fn draw_pgn_top(f: &mut ratatui::Frame<'_>, area: Rect, app: &mut App, state: &AppState) {
let rows = state.pgn_load_rows();
if !rows.is_empty() && app.pgn_top_state.selected().is_none() {
app.pgn_top_state.select(Some(0));
}
if let Some(sel) = app.pgn_top_state.selected()
&& sel >= rows.len()
{
app.pgn_top_state
.select(rows.len().checked_sub(1).or(Some(0)));
}
let max_rate = rows.iter().map(|r| r.rate).fold(0.0_f32, f32::max);
let total_rate: f32 = rows.iter().map(|r| r.rate).sum();
let items: Vec<ListItem> = rows
.iter()
.map(|r| ListItem::new(format_pgn_top_row(r, max_rate)))
.collect();
let title = format!(
" PGN Load — {} pgns, {:.0} msg/s total ",
rows.len(),
total_rate
);
let list = List::new(items)
.block(panel_block(title))
.highlight_style(
Style::default()
.bg(menu::SELECT_BG)
.fg(menu::SELECT_FG)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(" ▶ ")
.highlight_spacing(HighlightSpacing::Always);
f.render_stateful_widget(list, area, &mut app.pgn_top_state);
}
fn format_pgn_top_row(r: &PgnLoadRow, max_rate: f32) -> Line<'static> {
const BAR_W: usize = 24;
let filled = if max_rate > 0.0 {
((r.rate / max_rate) * BAR_W as f32).round() as usize
} else {
0
}
.min(BAR_W);
let bar = format!("{}{}", "█".repeat(filled), "░".repeat(BAR_W - filled));
let rate = if r.rate >= 1.0 {
format!("{:>6.1}/s", r.rate)
} else if r.rate > 0.0 {
format!("{:>6.2}/s", r.rate)
} else {
format!("{:>9}", "—")
};
Line::from(vec![
Span::styled(format!(" {:6} ", r.pgn), Style::default().fg(Color::Cyan)),
Span::raw(format!("{:32.32}", r.description)),
Span::styled(
format!(" {:>3} src", r.sources),
Style::default().fg(Color::Yellow),
),
Span::raw(format!(" {:>9}", r.count)),
Span::raw(format!(" {rate} ")),
Span::styled(bar, Style::default().fg(Color::Green)),
])
}
fn row_search_string(h: &crate::cli::tui::state::HistoryRecord) -> String {
let stamp = h.timestamp.as_deref().unwrap_or("");
let sec = h.secondary.as_deref().unwrap_or("");
format!(
"{stamp} src {} pgn {} {sec} {}",
h.src, h.pgn, h.description
)
}
fn format_time_row(idx: usize, h: &crate::cli::tui::state::HistoryRecord) -> Line<'static> {
let stamp: String = match h.timestamp.as_deref() {
Some(s) if !s.is_empty() => s.to_string(),
_ => format!("+{}s", h.seen_at.elapsed().as_secs()),
};
let sec = h
.secondary
.as_deref()
.map(|s| format!(":{s}"))
.unwrap_or_default();
Line::from(vec![
Span::styled(format!(" {idx:>6} "), Style::default().fg(Color::DarkGray)),
Span::styled(format!("{stamp:<24}"), Style::default().fg(Color::White)),
Span::styled(
format!(" src {:3}", h.src),
Style::default().fg(Color::Yellow),
),
Span::styled(
format!(" {:6}{sec:<10}", h.pgn),
Style::default().fg(Color::Cyan),
),
Span::raw(format!(" {}", h.description)),
])
}
fn panel_block(title: impl Into<String>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(PANEL_BORDER).bg(PANEL_BG))
.style(Style::default().bg(PANEL_BG).fg(PANEL_FG))
.title(Span::styled(
title.into(),
Style::default()
.fg(PANEL_TITLE)
.bg(PANEL_BG)
.add_modifier(Modifier::BOLD),
))
}
fn panel_highlight() -> Style {
Style::default().add_modifier(Modifier::REVERSED)
}
fn dialog_block(title: impl Into<String>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(Color::Cyan).bg(SURFACE_BG))
.style(Style::default().bg(SURFACE_BG).fg(SURFACE_FG))
.title(Span::styled(
title.into(),
Style::default()
.fg(SURFACE_ACCENT)
.bg(SURFACE_BG)
.add_modifier(Modifier::BOLD),
))
}
fn centered_rect(area: Rect, max_w: u16, max_h: u16) -> Rect {
let w = max_w.min(area.width.saturating_sub(2));
let h = max_h.min(area.height.saturating_sub(2));
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
Rect {
x,
y,
width: w,
height: h,
}
}
fn draw_modal(f: &mut ratatui::Frame<'_>, area: Rect, modal: &OverrideModal) {
let rect = centered_rect(area, 60, 8);
menu::draw_shadow(f, rect, area);
f.render_widget(Clear, rect);
let mfr = modal
.manufacturer_code
.map(|m| format!(" mfr={m}"))
.unwrap_or_default();
let ind = modal
.industry_code
.map(|i| format!(" ind={i}"))
.unwrap_or_default();
let title = match &modal.description {
Some(d) if !d.is_empty() => {
format!("Override PGN {} ({d}) on src {}", modal.pgn, modal.src)
}
_ => format!("Override PGN {} on src {}", modal.pgn, modal.src),
};
let text = vec![
Line::from(format!("{title}{mfr}{ind}")),
Line::from(""),
Line::from(format!("New interval (ms): {}_", modal.input)),
Line::from(""),
Line::from("Enter to send • Esc to cancel"),
Line::from(format!("{INTERVAL_OFF} = stop transmitting")),
];
let p = Paragraph::new(text)
.style(Style::default().bg(SURFACE_BG).fg(SURFACE_FG))
.block(dialog_block(" PGN Override "))
.wrap(Wrap { trim: true });
f.render_widget(p, rect);
}
fn draw_connecting_modal(f: &mut ratatui::Frame<'_>, area: Rect, state: &AppState) {
let rect = centered_rect(area, 64, 10);
menu::draw_shadow(f, rect, area);
f.render_widget(Clear, rect);
let s = &state.status;
let snap_status = if s.snapshot_loaded {
Span::styled(
"✓ loaded",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
} else if s
.last_error
.as_deref()
.is_some_and(|e| e.starts_with("snapshot"))
{
Span::styled(
"✗ failed",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
} else {
Span::styled("… connecting", Style::default().fg(Color::Yellow))
};
let stream_status = if s.stream_connected {
Span::styled(
"✓ connected",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
} else if s
.last_error
.as_deref()
.is_some_and(|e| e.starts_with("stream"))
{
Span::styled(
"✗ failed",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
} else {
Span::styled("… connecting", Style::default().fg(Color::Yellow))
};
let text = vec![
Line::from(format!("Connecting to {}", s.host)),
Line::from(""),
Line::from(vec![
Span::raw(format!(" Snapshot :{:<5} ", s.snapshot_port)),
snap_status,
]),
Line::from(vec![
Span::raw(format!(" Stream :{:<5} ", s.stream_port)),
stream_status,
]),
Line::from(""),
Line::from(Span::styled(
"Press any key to continue (auto-dismisses when both up)",
Style::default().bg(SURFACE_BG).fg(menu::DISABLED_FG),
)),
];
let p = Paragraph::new(text)
.style(Style::default().bg(SURFACE_BG).fg(SURFACE_FG))
.block(dialog_block(" canboat-tui "))
.wrap(Wrap { trim: true });
f.render_widget(p, rect);
}
fn draw_error_modal(f: &mut ratatui::Frame<'_>, area: Rect, message: &str) {
let rect = centered_rect(area, 72, 12);
menu::draw_shadow(f, rect, area);
f.render_widget(Clear, rect);
let surface = Style::default().bg(SURFACE_BG).fg(SURFACE_FG);
let text = vec![
Line::from(Span::styled(
"Fatal error",
surface.fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(message.to_string(), surface)),
Line::from(""),
Line::from(Span::styled(
"Press any key to dismiss",
surface.fg(SURFACE_ACCENT),
)),
];
let p = Paragraph::new(text)
.style(surface)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(surface.fg(Color::Red))
.style(surface)
.title(Span::styled(
" Error ",
surface.fg(Color::Red).add_modifier(Modifier::BOLD),
)),
)
.wrap(Wrap { trim: true });
f.render_widget(p, rect);
}
const SPLASH_FRAMES: u16 = 43;
const SPLASH_GROW: u16 = 15;
const SPLASH_LOAD_START: u16 = 30;
fn banner_glyph(c: char) -> [&'static str; 5] {
match c {
'C' => ["█████", "█ ", "█ ", "█ ", "█████"],
'A' => [" ███ ", "█ █", "█████", "█ █", "█ █"],
'N' => ["█ █", "██ █", "█ █ █", "█ ██", "█ █"],
'B' => ["████ ", "█ █", "████ ", "█ █", "████ "],
'O' => [" ███ ", "█ █", "█ █", "█ █", " ███ "],
'T' => ["█████", " █ ", " █ ", " █ ", " █ "],
_ => [" ", " ", " ", " ", " "],
}
}
fn banner_lines(word: &str) -> Vec<String> {
let mut rows = vec![String::new(); 5];
for (i, ch) in word.chars().enumerate() {
let g = banner_glyph(ch);
for (r, row) in rows.iter_mut().enumerate() {
if i > 0 {
row.push(' ');
}
row.push_str(g[r]);
}
}
rows
}
fn draw_splash(f: &mut ratatui::Frame<'_>, area: Rect, frame: u16, verb: &str) {
const BORDER: [Color; 7] = [
Color::Rgb(0xff, 0x00, 0x00),
Color::Rgb(0xff, 0xff, 0x00),
Color::Rgb(0x00, 0xff, 0xff),
Color::Rgb(0x00, 0xff, 0x00),
Color::Rgb(0xff, 0x00, 0xff),
Color::Rgb(0xff, 0xff, 0xff),
Color::Rgb(0x00, 0x00, 0xff),
];
let light_blue = Color::Rgb(0x8b, 0x8b, 0xff);
let blue = Style::default().bg(DESKTOP_BG);
f.render_widget(
Block::default().style(Style::default().bg(Color::Black)),
area,
);
let p = f32::from(frame.min(SPLASH_GROW)) / f32::from(SPLASH_GROW);
let bw = ((area.width as f32 * p).ceil() as u16).clamp(2, area.width);
let bh = ((area.height as f32 * p).ceil() as u16).clamp(2, area.height);
let bx = area.x + (area.width - bw) / 2;
let by = area.y + (area.height - bh) / 2;
let outer = Rect {
x: bx,
y: by,
width: bw,
height: bh,
};
f.render_widget(Clear, outer);
f.render_widget(
Block::default().style(Style::default().bg(BORDER[frame as usize % BORDER.len()])),
outer,
);
if bw <= 2 || bh <= 2 {
return;
}
let inner = Rect {
x: bx + 1,
y: by + 1,
width: bw - 2,
height: bh - 2,
};
f.render_widget(Block::default().style(blue), inner);
let banner = banner_lines("CANBOAT");
let banner_w = banner.first().map(|s| s.chars().count()).unwrap_or(0) as u16;
if inner.width < banner_w || inner.height < 12 {
return;
}
let mut lines: Vec<Line> = banner
.iter()
.map(|row| {
Line::from(Span::styled(
row.clone(),
blue.fg(light_blue).add_modifier(Modifier::BOLD),
))
})
.collect();
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("**** CANBOAT-TUI V{} ****", env!("CARGO_PKG_VERSION")),
blue.fg(light_blue),
)));
lines.push(Line::from(Span::styled(
"(C) 2009-2026 KEES VERRUIJT, HARLINGEN, THE NETHERLANDS",
blue.fg(light_blue),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("READY.", blue.fg(light_blue))));
let cursor = if (frame / 3).is_multiple_of(2) {
"█"
} else {
" "
};
lines.push(Line::from(Span::styled(cursor, blue.fg(light_blue))));
lines.push(Line::from(""));
let bottom = if frame >= SPLASH_LOAD_START {
let dots = ".".repeat(1 + (frame as usize / 2) % 3);
format!("{verb} {dots}")
} else if (frame / 4).is_multiple_of(2) {
"PRESS ANY KEY".to_string()
} else {
String::new()
};
lines.push(Line::from(Span::styled(
bottom,
blue.fg(Color::Rgb(0xff, 0xff, 0xff))
.add_modifier(Modifier::BOLD),
)));
let text_area = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height.saturating_sub(1),
};
let para = Paragraph::new(lines)
.alignment(Alignment::Center)
.style(blue);
f.render_widget(para, text_area);
}
fn swap_extension(input: &str, ext: &str) -> String {
match input.rsplit_once('.') {
Some((stem, old))
if old.eq_ignore_ascii_case("json") || old.eq_ignore_ascii_case("raw") =>
{
format!("{stem}.{ext}")
}
_ => format!("{input}.{ext}"),
}
}
fn draw_progress_modal(f: &mut ratatui::Frame<'_>, area: Rect, p: &Progress) {
let rect = centered_rect(area, 56, 7);
menu::draw_shadow(f, rect, area);
f.render_widget(Clear, rect);
let surface = Style::default().bg(SURFACE_BG).fg(SURFACE_FG);
let block = dialog_block(" Saving ");
let inner = block.inner(rect);
f.render_widget(block, rect);
let pct = p
.done
.saturating_mul(100)
.checked_div(p.total)
.unwrap_or(100) as u16;
let bar_w = inner.width.saturating_sub(2) as usize;
let filled = bar_w
.saturating_mul(p.done)
.checked_div(p.total)
.unwrap_or(bar_w);
let bar: String = std::iter::repeat_n('█', filled)
.chain(std::iter::repeat_n('░', bar_w.saturating_sub(filled)))
.collect();
let text = vec![
Line::from(Span::styled(format!(" {}", p.label), surface)),
Line::from(""),
Line::from(Span::styled(
format!(" {bar}"),
surface.fg(SURFACE_ACCENT).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" {} / {} records ({pct}%)", p.done, p.total),
surface,
)),
];
f.render_widget(Paragraph::new(text).style(surface), inner);
}
fn draw_io_modal(f: &mut ratatui::Frame<'_>, area: Rect, m: &IoModal) {
let rect = centered_rect(area, 68, 8);
menu::draw_shadow(f, rect, area);
f.render_widget(Clear, rect);
let surface = Style::default().bg(SURFACE_BG).fg(SURFACE_FG);
let text = vec![
Line::from(Span::styled(
"Endpoint (host [snapshot-port [stream-port]]):",
surface,
)),
Line::from(""),
Line::from(Span::styled(
format!("{}_", m.input),
surface.fg(SURFACE_ACCENT).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled("Enter to confirm • Esc to cancel", surface)),
Line::from(Span::styled(
"Ports default to 2597 / 2598 when omitted.",
surface.fg(menu::DISABLED_FG),
)),
];
let p = Paragraph::new(text)
.style(surface)
.block(dialog_block(" Connect "))
.wrap(Wrap { trim: true });
f.render_widget(p, rect);
}
fn fit_width(s: &str, w: usize) -> String {
let n = s.chars().count();
if n > w {
let keep = w.saturating_sub(1);
format!("{}…", s.chars().take(keep).collect::<String>())
} else {
format!("{s:<w$}")
}
}
fn human_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"];
let mut v = bytes as f64;
let mut u = 0;
while v >= 1024.0 && u < UNITS.len() - 1 {
v /= 1024.0;
u += 1;
}
if u == 0 {
format!("{bytes}B")
} else if v >= 100.0 {
format!("{v:.0}{}", UNITS[u])
} else {
format!("{v:.1}{}", UNITS[u])
}
}
fn format_mtime(t: std::time::SystemTime) -> String {
chrono::DateTime::<chrono::Local>::from(t)
.format("%Y-%m-%d %H:%M")
.to_string()
}
fn truncate_start(s: &str, max: usize) -> String {
let count = s.chars().count();
if count <= max {
return s.to_string();
}
let skip = count - max.saturating_sub(1);
format!("…{}", s.chars().skip(skip).collect::<String>())
}
fn draw_file_browser(f: &mut ratatui::Frame<'_>, area: Rect, fb: &FileBrowser) {
let surface = Style::default().bg(SURFACE_BG).fg(SURFACE_FG);
let reserved: u16 = if fb.save { 3 } else { 1 };
let w = 76.min(area.width.saturating_sub(4)).max(24);
let h = (fb.entries.len() as u16 + 2 + reserved).clamp(8, area.height.saturating_sub(4));
let rect = centered_rect(area, w, h);
menu::draw_shadow(f, rect, area);
f.render_widget(Clear, rect);
let verb = if fb.save { "Save" } else { "Load" };
let cwd = fb.cwd.display().to_string();
let title = if fb.error.is_some() {
format!(" {verb} — {} [unreadable] ", truncate_start(&cwd, 30))
} else {
format!(
" {verb} — {} ",
truncate_start(&cwd, rect.width.saturating_sub(12) as usize)
)
};
let block = dialog_block(title);
let inner = block.inner(rect);
f.render_widget(block, rect);
let list_h = inner.height.saturating_sub(reserved);
let list_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: list_h,
};
const SIZE_W: usize = 8;
const DATE_W: usize = 16; let avail = list_area.width.saturating_sub(3) as usize; let show_meta = avail >= SIZE_W + DATE_W + 6;
let name_w = if show_meta {
avail - SIZE_W - DATE_W - 2
} else {
avail
};
let items: Vec<ListItem> = fb
.entries
.iter()
.map(|e| {
let display = if e.is_dir {
format!("{}/", e.name)
} else {
e.name.clone()
};
let name_style = if e.is_dir {
surface.fg(SURFACE_ACCENT).add_modifier(Modifier::BOLD)
} else {
surface
};
let mut spans = vec![Span::styled(fit_width(&display, name_w), name_style)];
if show_meta {
let size_str = if e.name == ".." {
String::new()
} else if e.is_dir {
"<DIR>".to_string()
} else {
human_size(e.size)
};
let date_str = e.modified.map(format_mtime).unwrap_or_default();
spans.push(Span::styled(
format!(" {size_str:>SIZE_W$} {date_str:<DATE_W$}"),
surface.fg(menu::DISABLED_FG),
));
}
ListItem::new(Line::from(spans))
})
.collect();
let mut list_state = ListState::default();
if !fb.entries.is_empty() {
list_state.select(Some(fb.selected));
}
let list = List::new(items)
.style(surface)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol(" ▶ ")
.highlight_spacing(HighlightSpacing::Always);
f.render_stateful_widget(list, list_area, &mut list_state);
let mut y = inner.y + list_h;
let mut row = |f: &mut ratatui::Frame<'_>, line: Line<'static>| {
f.render_widget(
Paragraph::new(line).style(surface),
Rect {
x: inner.x,
y,
width: inner.width,
height: 1,
},
);
y += 1;
};
if fb.save {
row(
f,
Line::from(vec![
Span::styled(" Name: ", surface),
Span::styled(
format!("{}_", fb.filename),
surface.fg(SURFACE_ACCENT).add_modifier(Modifier::BOLD),
),
]),
);
let sel = surface
.fg(SURFACE_ACCENT)
.add_modifier(Modifier::BOLD | Modifier::REVERSED);
let (analysed, raw) = match fb.format {
SaveFormat::Analysed => (sel, surface),
SaveFormat::Raw => (surface, sel),
};
row(
f,
Line::from(vec![
Span::styled(" Format (Tab): ", surface),
Span::styled(" Analysed (.json) ", analysed),
Span::styled(" ", surface),
Span::styled(" Raw (.raw) ", raw),
]),
);
row(
f,
Line::from(Span::styled(
" ↑↓ move | → open dir | ← up | type name | Enter save | Esc cancel",
surface.fg(SURFACE_ACCENT),
)),
);
} else {
row(
f,
Line::from(Span::styled(
" ↑↓ move | Enter open | ← parent | Esc cancel",
surface.fg(SURFACE_ACCENT),
)),
);
}
}
fn draw_help_modal(f: &mut ratatui::Frame<'_>, area: Rect) {
let rect = centered_rect(area, 66, 22);
menu::draw_shadow(f, rect, area);
f.render_widget(Clear, rect);
let surface = Style::default().bg(SURFACE_BG).fg(SURFACE_FG);
let head = surface.fg(SURFACE_ACCENT).add_modifier(Modifier::BOLD);
let text = vec![
Line::from(Span::styled(
format!("canboat-tui v{}", env!("CARGO_PKG_VERSION")),
head,
)),
Line::from(Span::styled("Interactive NMEA 2000 bus viewer", surface)),
Line::from(""),
Line::from(Span::styled("Menu", head)),
Line::from(Span::styled(" F10 open the menu bar", surface)),
Line::from(Span::styled(
" Alt-F/V/D/S/H jump straight to a menu",
surface,
)),
Line::from(Span::styled(
" ←→ ↑↓ Enter navigate • Esc closes",
surface,
)),
Line::from(""),
Line::from(Span::styled("Views", head)),
Line::from(Span::styled(
" d devices t timeline p PGN load n NMEA 0183",
surface,
)),
Line::from(""),
Line::from(Span::styled("Navigation", head)),
Line::from(Span::styled(
" ↑↓ / j k move Enter open / drill in Esc back",
surface,
)),
Line::from(""),
Line::from(Span::styled("Device (live bus)", head)),
Line::from(Span::styled(
" i ISO Request 126464 o override interval",
surface,
)),
Line::from(""),
Line::from(Span::styled("Timeline", head)),
Line::from(Span::styled(
" / search n/N next/prev f filter pgns s filter srcs",
surface,
)),
Line::from(""),
Line::from(Span::styled(
"Press any key to close",
surface.fg(SURFACE_ACCENT),
)),
];
let p = Paragraph::new(text)
.style(surface)
.block(dialog_block(" Help "))
.wrap(Wrap { trim: false });
f.render_widget(p, rect);
}
fn draw_text_prompt(f: &mut ratatui::Frame<'_>, area: Rect, prompt: &TextPrompt) {
let prefix = match prompt.kind {
TextPromptKind::Search => "/",
TextPromptKind::FilterPgns => "filter pgns: ",
};
let text = format!(" {prefix}{}_", prompt.buffer);
let p = Paragraph::new(text).style(
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
f.render_widget(p, area);
}
fn draw_src_select_modal(f: &mut ratatui::Frame<'_>, area: Rect, sel: &SrcSelect) {
let w = 60.min(area.width.saturating_sub(2));
let h = (sel.sources.len() as u16 + 6)
.min(area.height.saturating_sub(2))
.max(6);
let rect = centered_rect(area, w, h);
let surface = Style::default().bg(SURFACE_BG).fg(SURFACE_FG);
menu::draw_shadow(f, rect, area);
f.render_widget(Clear, rect);
let items: Vec<ListItem> = sel
.sources
.iter()
.map(|(src, label)| {
let mark = if sel.selected.contains(src) {
"[x]"
} else {
"[ ]"
};
ListItem::new(Line::from(vec![
Span::styled(
format!(" {mark} "),
surface.fg(SURFACE_ACCENT).add_modifier(Modifier::BOLD),
),
Span::styled(label.clone(), surface),
]))
})
.collect();
let mut list_state = ListState::default();
list_state.select(Some(sel.cursor));
let title = format!(
" Filter sources ({}/{}) ",
sel.selected.len(),
sel.sources.len()
);
let list = List::new(items)
.block(dialog_block(title))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol(" ▶ ")
.highlight_spacing(HighlightSpacing::Always);
f.render_stateful_widget(list, rect, &mut list_state);
if rect.height >= 2 {
let hint_area = Rect {
x: rect.x + 1,
y: rect.y + rect.height - 1,
width: rect.width.saturating_sub(2),
height: 1,
};
let hint = Paragraph::new(" Space toggle | a toggle-all | Enter apply | Esc cancel ")
.style(surface.fg(SURFACE_ACCENT));
f.render_widget(hint, hint_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::tui::state::Status;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use serde_json::json;
use std::collections::HashMap;
fn seeded_state() -> AppState {
let mut s = AppState::new(Status::new_log("cap.log".into()), HashMap::new());
s.status.snapshot_loaded = true;
s.upsert(
60928,
10,
None,
"ISO Address Claim".into(),
json!({"pgn": 60928, "fields": {"Manufacturer Code": "Furuno"}}),
);
s.upsert(
129025,
10,
None,
"Position, Rapid Update".into(),
json!({"pgn": 129025, "fields": {"Latitude": 53.1}}),
);
s.upsert(
129025,
10,
None,
"Position, Rapid Update".into(),
json!({"pgn": 129025, "fields": {"Latitude": 53.2}}),
);
s
}
#[test]
fn advertised_tx_pgns_become_overridable_rows() {
let mut s = AppState::new(Status::new_log("cap.log".into()), HashMap::new());
s.upsert(
60928,
52,
None,
"ISO Address Claim".into(),
json!({"pgn":60928,"fields":{"Manufacturer Code":1855,"Unique Number":42}}),
);
s.upsert(
127250,
52,
None,
"Vessel Heading".into(),
json!({"pgn":127250,"fields":{"Heading":1.0}}),
);
s.upsert(
126464,
52,
None,
"PGN List".into(),
json!({"pgn":126464,"fields":{"Function Code":0,
"list":[{"PGN":130842},{"PGN":127250}]}}),
);
let rows = ui_app().detail_rows(52, &s);
let advertised: Vec<_> = rows.iter().filter(|e| e.count == 0).collect();
assert_eq!(advertised.len(), 1);
let row = advertised[0];
assert_eq!(row.pgn, 130842);
assert!(
!row.line.is_null(),
"advertised rows carry a synthetic line"
);
assert_eq!(
rows.iter().filter(|e| e.pgn == 127250).count(),
1,
"the live PGN is not re-advertised"
);
assert_eq!(proprietary_codes(row), (Some(1855), Some(4)));
}
#[test]
fn advertised_variant_picks_the_device_manufacturer() {
let v = advertised_variant(130842, Some(1855)).expect("furuno variant");
assert_eq!(variant_manufacturer(v), Some(1855));
assert_eq!(variant_industry(v), Some(4));
assert!(v.description.contains("Furuno"));
assert!(advertised_variant(130842, None).is_none());
assert!(advertised_variant(127250, None).is_some());
}
fn ui_app() -> App {
let mut app = App::new();
app.splash = None;
app.connecting_dismissed = true;
app
}
fn splash_buffer(frame: u16, w: u16, h: u16) -> String {
let state = seeded_state();
let mut app = App::new();
app.splash = Some(frame);
let backend = TestBackend::new(w, h);
let mut term = Terminal::new(backend).unwrap();
term.draw(|f| render(f, &mut app, &state)).unwrap();
buffer_text(&term)
}
fn buffer_text(term: &Terminal<TestBackend>) -> String {
let buf = term.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "));
}
out.push('\n');
}
out
}
fn draw_to_buffer(app: &mut App, state: &AppState) -> String {
let backend = TestBackend::new(120, 30);
let mut term = Terminal::new(backend).unwrap();
term.draw(|f| render(f, app, state)).unwrap();
buffer_text(&term)
}
#[test]
fn timeline_windows_only_the_viewport() {
let mut state = seeded_state();
for i in 0..5000 {
state.upsert(
127250,
10,
None,
"Heading".into(),
json!({"pgn": 127250, "i": i}),
);
}
let app = ui_app();
let total = app.visible_history_len(&state);
assert!(total >= 5000);
let w = app.visible_window(&state, 100, 30);
assert_eq!(w.len(), 30);
assert_eq!(w[0], 100);
let tail = app.visible_window(&state, total - 5, 30);
assert_eq!(tail.len(), 5);
}
#[test]
fn timeline_scrolls_to_keep_selection_visible() {
let mut state = seeded_state();
for i in 0..1000 {
state.upsert(
127250,
10,
None,
"Heading".into(),
json!({"pgn": 127250, "i": i}),
);
}
let mut app = ui_app();
app.screen = Screen::TimeView;
let total = app.visible_history_len(&state);
app.time_state.select(Some(total - 1));
let _ = draw_to_buffer(&mut app, &state);
assert!(app.time_offset > 0, "offset should track the selection");
assert!(app.time_offset < total, "offset stays within bounds");
app.time_state.select(Some(0));
let _ = draw_to_buffer(&mut app, &state);
assert_eq!(app.time_offset, 0);
}
#[test]
fn every_screen_renders_without_panic() {
let state = seeded_state();
for screen in [
Screen::Devices,
Screen::TimeView,
Screen::PgnTop,
Screen::Nmea0183,
Screen::Overrides,
Screen::DeviceDetail { src: 10 },
] {
let mut app = ui_app();
app.screen = screen;
let _ = draw_to_buffer(&mut app, &state);
}
}
#[test]
fn splash_animates_and_ends() {
let _ = splash_buffer(0, 120, 30);
let out = splash_buffer(SPLASH_GROW, 120, 30);
assert!(out.contains("READY."), "C64 boot line missing");
assert!(out.contains("KEES VERRUIJT"), "copyright missing");
let out = splash_buffer(SPLASH_LOAD_START + 2, 120, 30);
assert!(out.contains("LOADING"), "loading tail missing");
let mut app = App::new();
app.splash = Some(SPLASH_FRAMES - 1);
let state = seeded_state();
let backend = TestBackend::new(120, 30);
let mut term = Terminal::new(backend).unwrap();
term.draw(|f| render(f, &mut app, &state)).unwrap();
assert!(app.splash.is_none(), "splash should end after last frame");
}
#[test]
fn menu_bar_shows_titles_and_every_dropdown_renders() {
let state = seeded_state();
let mut app = ui_app();
let out = draw_to_buffer(&mut app, &state);
for title in ["File", "View", "Device", "Search", "Help"] {
assert!(out.contains(title), "menu title {title} missing from bar");
}
let n = app.build_menus(&state).len();
for i in 0..n {
app.menu.open = Some(i);
app.menu.item = app.build_menus(&state)[i].first_item();
let _ = draw_to_buffer(&mut app, &state);
}
}
#[test]
fn pgn_load_view_lists_the_busy_pgn() {
let state = seeded_state();
let mut app = ui_app();
app.screen = Screen::PgnTop;
let out = draw_to_buffer(&mut app, &state);
assert!(
out.contains("129025"),
"PGN 129025 should appear in load view"
);
assert!(out.contains("PGN Load"), "load view title missing");
}
#[test]
fn help_modal_renders() {
let state = seeded_state();
let mut app = ui_app();
app.help_visible = true;
let out = draw_to_buffer(&mut app, &state);
assert!(out.contains("Help"), "help title missing");
assert!(out.contains("F10"), "help should document F10");
}
#[test]
fn connect_dialog_renders() {
let state = seeded_state();
let mut app = ui_app();
app.open_connect_modal(&state);
let out = draw_to_buffer(&mut app, &state);
assert!(out.contains("Connect"), "Connect dialog title missing");
}
#[test]
fn file_browser_renders_and_lists_entries() {
let state = seeded_state();
let mut app = ui_app();
app.open_file_browser();
let out = draw_to_buffer(&mut app, &state);
assert!(out.contains("Load"), "browser title missing");
assert!(out.contains(".."), "parent entry missing");
}
#[test]
fn connect_dialog_parses_host_and_ports() {
let mut app = ui_app();
app.io_modal = Some(IoModal {
input: "pi.local 1234 5678".into(),
});
app.handle_io_modal_key(KeyEvent::from(KeyCode::Enter));
match app.pending_command {
Some(PendingCommand::Connect {
host,
snapshot_port,
stream_port,
}) => {
assert_eq!(host, "pi.local");
assert_eq!(snapshot_port, 1234);
assert_eq!(stream_port, 5678);
}
_ => panic!("expected a Connect command"),
}
}
#[test]
fn save_browser_toggles_format_types_name_and_saves() {
let mut app = ui_app();
app.open_save_browser();
let fb = app.file_browser.as_ref().unwrap();
assert!(fb.save && fb.filename.ends_with(".json"));
app.handle_file_browser_key(KeyEvent::from(KeyCode::Tab));
let fb = app.file_browser.as_ref().unwrap();
assert_eq!(fb.format, SaveFormat::Raw);
assert!(fb.filename.ends_with(".raw"));
app.handle_file_browser_key(KeyEvent::from(KeyCode::Char('X')));
assert!(app.file_browser.as_ref().unwrap().filename.ends_with('X'));
let expected = app.file_browser.as_ref().unwrap().save_path();
app.handle_file_browser_key(KeyEvent::from(KeyCode::Enter));
match app.pending_command {
Some(PendingCommand::Save { path, format }) => {
assert_eq!(path, expected);
assert_eq!(format, SaveFormat::Raw);
}
_ => panic!("expected a Save command"),
}
}
#[test]
fn on_request_pgns_are_flagged() {
assert!(pgn_on_request(60928)); assert!(pgn_on_request(126996)); assert!(pgn_on_request(126998)); assert!(!pgn_on_request(126993)); assert!(!pgn_on_request(129025)); }
#[test]
fn robust_interval_ignores_merge_gaps() {
let mut s = seeded_state();
let ts =
|ms: i64| json!({"pgn": 126993, "timestamp": crate::engine::format_iso_ms(ms as u64)});
let mut t = 0i64;
for _ in 0..10 {
s.upsert(126993, 20, None, "Heartbeat".into(), ts(t));
t += 60_000;
}
t += 6 * 3_600_000; for _ in 0..10 {
s.upsert(126993, 20, None, "Heartbeat".into(), ts(t));
t += 60_000;
}
let e = s.entries.get(&(126993, 20, None)).unwrap();
assert!(e.interval().unwrap().as_secs() > 1_000);
let robust = robust_interval(e, &s).unwrap().as_secs();
assert!((59..=61).contains(&robust), "got {robust}s");
}
#[test]
fn human_size_is_compact() {
assert_eq!(human_size(0), "0B");
assert_eq!(human_size(512), "512B");
assert_eq!(human_size(1024), "1.0K");
assert_eq!(human_size(1536), "1.5K");
assert_eq!(human_size(734_003_200), "700M"); }
#[test]
fn mtime_formats_as_local_ymd_hm() {
use std::time::{Duration, UNIX_EPOCH};
let s = format_mtime(UNIX_EPOCH + Duration::from_secs(1_609_459_200));
assert_eq!(s.len(), 16, "{s:?}");
let b = s.as_bytes();
assert_eq!((b[4], b[7], b[10], b[13]), (b'-', b'-', b' ', b':'));
assert_eq!(s.chars().filter(|c| c.is_ascii_digit()).count(), 12);
}
#[test]
fn open_dialog_swallows_menu_and_help_keys() {
let state = seeded_state();
let (writer, _rx) = crate::cli::tui::client::make_writer();
let mut app = ui_app();
app.open_file_browser();
app.handle_key(KeyEvent::from(KeyCode::F(10)), &state, &writer);
assert!(!app.menu.is_open(), "F10 leaked to the menu behind browser");
app.handle_key(
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT),
&state,
&writer,
);
assert!(
!app.menu.is_open(),
"Alt+F leaked to the menu behind browser"
);
app.handle_key(KeyEvent::from(KeyCode::F(1)), &state, &writer);
assert!(!app.help_visible, "F1 leaked to Help behind browser");
assert!(app.file_browser.is_some(), "browser stayed open");
}
#[test]
fn browser_remembers_directory_across_opens() {
let root = std::env::temp_dir();
let mut app = ui_app();
app.browse_dir = Some(root.clone());
app.open_file_browser();
assert_eq!(app.file_browser.as_ref().unwrap().cwd, root);
app.handle_file_browser_key(KeyEvent::from(KeyCode::Esc));
assert!(app.file_browser.is_none());
assert_eq!(app.browse_dir.as_ref(), Some(&root));
app.open_save_browser();
assert_eq!(app.file_browser.as_ref().unwrap().cwd, root);
app.handle_file_browser_key(KeyEvent::from(KeyCode::Left));
let cwd = app.file_browser.as_ref().unwrap().cwd.clone();
assert_eq!(app.browse_dir.as_ref(), Some(&cwd));
assert_ne!(cwd, root);
}
#[test]
fn prompt_load_opens_file_browser() {
let mut app = ui_app();
app.prompt_load();
assert!(
app.file_browser.is_some(),
"startup should open the browser"
);
let out = draw_to_buffer(&mut app, &seeded_state());
assert!(out.contains("Load"), "browser should be visible");
}
#[test]
fn connect_dialog_defaults_ports_when_omitted() {
let mut app = ui_app();
app.io_modal = Some(IoModal {
input: "boat".into(),
});
app.handle_io_modal_key(KeyEvent::from(KeyCode::Enter));
match app.pending_command {
Some(PendingCommand::Connect {
host,
snapshot_port,
stream_port,
}) => {
assert_eq!(host, "boat");
assert_eq!(snapshot_port, 2597);
assert_eq!(stream_port, 2598);
}
_ => panic!("expected a Connect command"),
}
}
}