Version 1.0.0

This commit is contained in:
leviathan
2026-02-07 17:35:27 -05:00
parent c8bff9afd7
commit 4339895b41
43 changed files with 12218 additions and 3 deletions
+258
View File
@@ -0,0 +1,258 @@
//! Captures list widget with detail panel.
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Wrap},
Frame,
};
use crate::app::App;
use crate::capture::CaptureStatus;
/// Render the captures area: table + detail panel
pub fn render_captures_list(frame: &mut Frame, area: Rect, app: &App) {
// Split vertically: table on top, detail panel on bottom
let has_selection = app
.selected_capture
.map(|i| i < app.captures.len())
.unwrap_or(false);
let chunks = if has_selection {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(6), // Table (flexible, takes remaining)
Constraint::Length(12), // Detail panel (fixed height)
])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(6)])
.split(area)
};
render_table(frame, chunks[0], app);
if has_selection && chunks.len() > 1 {
render_detail_panel(frame, chunks[1], app);
}
}
/// Render the compact signal table
fn render_table(frame: &mut Frame, area: Rect, app: &App) {
let header_cells = [
"ID", "Time", "Protocol", "Freq", "Serial", "Btn", "Cnt", "Mod", "CRC", "Status",
]
.iter()
.map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD)));
let header = Row::new(header_cells).style(Style::default()).height(1);
let rows = app.captures.iter().map(|capture| {
let status_style = match capture.status {
CaptureStatus::Unknown => Style::default().fg(Color::DarkGray),
CaptureStatus::Decoded => Style::default().fg(Color::Yellow),
CaptureStatus::EncoderCapable => Style::default().fg(Color::Green),
};
let crc_style = if capture.protocol.is_none() {
Style::default().fg(Color::DarkGray)
} else if capture.crc_valid {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Red)
};
let mod_style = match capture.modulation() {
crate::capture::ModulationType::Pwm => Style::default().fg(Color::Magenta),
crate::capture::ModulationType::Manchester => Style::default().fg(Color::Cyan),
crate::capture::ModulationType::DifferentialManchester => {
Style::default().fg(Color::Blue)
}
crate::capture::ModulationType::Unknown => Style::default().fg(Color::DarkGray),
};
let status_text = match capture.status {
CaptureStatus::EncoderCapable => "✓ Encode",
CaptureStatus::Decoded => "Decoded",
CaptureStatus::Unknown => "Unknown",
};
Row::new(vec![
Cell::from(format!("{:02}", capture.id)),
Cell::from(capture.timestamp_short()),
Cell::from(capture.protocol_name().to_string()),
Cell::from(capture.frequency_mhz()),
Cell::from(capture.serial_hex()),
Cell::from(capture.button_name().to_string()),
Cell::from(capture.counter_str()),
Cell::from(capture.modulation().to_string()).style(mod_style),
Cell::from(capture.crc_status()).style(crc_style),
Cell::from(status_text).style(status_style),
])
.height(1)
});
let widths = [
Constraint::Length(4), // ID
Constraint::Length(9), // Time
Constraint::Length(10), // Protocol
Constraint::Length(11), // Freq
Constraint::Length(9), // Serial
Constraint::Length(6), // Btn
Constraint::Length(6), // Cnt
Constraint::Length(7), // Mod
Constraint::Length(5), // CRC
Constraint::Length(10), // Status
];
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Captures "),
)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = TableState::default();
state.select(app.selected_capture);
// Apply scroll offset if needed
if app.scroll_offset > 0 && app.selected_capture.is_some() {
*state.offset_mut() = app.scroll_offset;
}
frame.render_stateful_widget(table, area, &mut state);
}
/// Render the detail panel for the selected signal
fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
let capture = match app.selected_capture {
Some(idx) if idx < app.captures.len() => &app.captures[idx],
_ => return,
};
let label_style = Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let accent_style = Style::default().fg(Color::Cyan);
let good_style = Style::default().fg(Color::Green);
let bad_style = Style::default().fg(Color::Red);
// Build detail content in two columns
let make = App::get_make_for_protocol(capture.protocol_name());
// --- Left column lines ---
let mut left_lines = Vec::new();
// Row 1: Protocol + Make
left_lines.push(Line::from(vec![
Span::styled(" Protocol: ", label_style),
Span::styled(capture.protocol_name(), accent_style),
Span::styled(" Make: ", label_style),
Span::styled(make, value_style),
]));
// Row 2: Frequency + Modulation + Encryption
left_lines.push(Line::from(vec![
Span::styled(" Freq: ", label_style),
Span::styled(capture.frequency_mhz(), value_style),
Span::styled(" Mod: ", label_style),
Span::styled(capture.modulation().to_string(), value_style),
Span::styled(" Enc: ", label_style),
Span::styled(capture.encryption_type(), value_style),
]));
// Row 3: Full Serial + Button
left_lines.push(Line::from(vec![
Span::styled(" Serial: ", label_style),
Span::styled(format!("0x{}", capture.serial_hex()), accent_style),
Span::styled(" Btn: ", label_style),
Span::styled(
format!("{} ({})", capture.button_name(), capture.button_hex()),
value_style,
),
]));
// Row 4: Counter + CRC
let crc_span = if capture.protocol.is_none() {
Span::styled("-", Style::default().fg(Color::DarkGray))
} else if capture.crc_valid {
Span::styled("OK ✓", good_style)
} else {
Span::styled("FAIL ✗", bad_style)
};
left_lines.push(Line::from(vec![
Span::styled(" Counter: ", label_style),
Span::styled(capture.counter_str(), value_style),
Span::styled(" CRC: ", label_style),
crc_span,
Span::styled(" Status: ", label_style),
Span::styled(capture.status.to_string(), value_style),
]));
// Row 5: Full data/key hex
left_lines.push(Line::from(vec![
Span::styled(" Key/Data: ", label_style),
Span::styled(
format!("0x{}", capture.data_hex()),
Style::default().fg(Color::Yellow),
),
Span::styled(
format!(" ({})", capture.data_bits_str()),
Style::default().fg(Color::DarkGray),
),
]));
// Row 6: Timestamp + Raw data info
let raw_info = if capture.has_raw_data() {
format!("{} transitions", capture.raw_pair_count())
} else {
"None".to_string()
};
let raw_style = if capture.has_raw_data() {
good_style
} else {
Style::default().fg(Color::DarkGray)
};
left_lines.push(Line::from(vec![
Span::styled(" Captured: ", label_style),
Span::styled(capture.timestamp_full(), value_style),
]));
left_lines.push(Line::from(vec![
Span::styled(" Raw Data: ", label_style),
Span::styled(raw_info, raw_style),
]));
// Build the title
let title = format!(
" Signal #{:02}{} ",
capture.id,
capture.protocol_name()
);
let border_style = match capture.status {
CaptureStatus::EncoderCapable => Style::default().fg(Color::Green),
CaptureStatus::Decoded => Style::default().fg(Color::Yellow),
CaptureStatus::Unknown => Style::default().fg(Color::DarkGray),
};
let detail = Paragraph::new(left_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(title),
)
.wrap(Wrap { trim: false });
frame.render_widget(detail, area);
}
+81
View File
@@ -0,0 +1,81 @@
//! Command input widget.
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::{App, InputMode};
/// Render the command input line
pub fn render_command_line(frame: &mut Frame, area: Rect, app: &App) {
let (input_text, mode_text, mode_style) = match app.input_mode {
InputMode::Normal => (
String::new(),
"NORMAL",
Style::default().fg(Color::Green),
),
InputMode::Command => (
format!(":{}", app.command_input),
"COMMAND",
Style::default().fg(Color::Yellow),
),
InputMode::SignalMenu => (
String::new(),
"SIGNAL",
Style::default().fg(Color::Cyan),
),
InputMode::SettingsSelect => (
String::new(),
"SETTINGS",
Style::default().fg(Color::Cyan),
),
InputMode::SettingsEdit => (
String::new(),
"EDIT",
Style::default().fg(Color::Green),
),
InputMode::StartupImport => (
String::new(),
"IMPORT",
Style::default().fg(Color::Yellow),
),
InputMode::FobMetaYear
| InputMode::FobMetaMake
| InputMode::FobMetaModel
| InputMode::FobMetaRegion
| InputMode::FobMetaNotes => (
String::new(),
"EXPORT",
Style::default().fg(Color::Green),
),
};
let input_line = Line::from(vec![
Span::styled(
format!(" {} ", mode_text),
mode_style.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw(input_text),
Span::styled(
if app.input_mode == InputMode::Command {
""
} else {
""
},
Style::default(),
),
]);
let input = Paragraph::new(input_line).block(
Block::default()
.borders(Borders::ALL)
.title("input"),
);
frame.render_widget(input, area);
}
+388
View File
@@ -0,0 +1,388 @@
//! Main UI layout.
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
Frame,
};
use crate::app::{App, InputMode, RadioState};
use super::captures_list::render_captures_list;
use super::command::render_command_line;
use super::settings_menu::{render_settings_dropdown, render_settings_tabs};
use super::signal_menu::render_signal_menu;
use super::status_bar::render_status_bar;
use crate::app::InputMode as IM;
const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Draw the entire UI
pub fn draw_ui(frame: &mut Frame, app: &App) {
let show_settings = matches!(app.input_mode, IM::SettingsSelect | IM::SettingsEdit);
let show_command = app.input_mode == IM::Command;
let mut constraints = vec![Constraint::Length(3)]; // Header
if show_settings {
constraints.push(Constraint::Length(3)); // Settings tabs
}
constraints.push(Constraint::Min(8)); // Captures list
constraints.push(Constraint::Length(3)); // Status bar
if show_command {
constraints.push(Constraint::Length(3)); // Command input
}
constraints.push(Constraint::Length(1)); // Help bar
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(frame.area());
let mut idx = 0;
render_header(frame, chunks[idx], app);
idx += 1;
if show_settings {
render_settings_tabs(frame, chunks[idx], app);
idx += 1;
}
render_captures_list(frame, chunks[idx], app);
idx += 1;
render_status_bar(frame, chunks[idx], app);
idx += 1;
if show_command {
render_command_line(frame, chunks[idx], app);
idx += 1;
}
render_help_bar(frame, chunks[idx], app);
// Overlay widgets (rendered on top of everything else)
if app.input_mode == InputMode::SignalMenu {
render_signal_menu(frame, app);
}
if app.input_mode == InputMode::SettingsEdit {
render_settings_dropdown(frame, app);
}
if app.input_mode == InputMode::StartupImport {
render_startup_import_prompt(frame, app);
}
if matches!(
app.input_mode,
InputMode::FobMetaYear
| InputMode::FobMetaMake
| InputMode::FobMetaModel
| InputMode::FobMetaRegion
| InputMode::FobMetaNotes
) {
render_fob_metadata_form(frame, app);
}
}
/// Render the header with title and radio status
fn render_header(frame: &mut Frame, area: Rect, app: &App) {
let (status_symbol, status_style) = match app.radio_state {
RadioState::Disconnected => ("", Style::default().fg(Color::DarkGray)),
RadioState::Idle => ("", Style::default().fg(Color::Yellow)),
RadioState::Receiving => ("", Style::default().fg(Color::Green)),
RadioState::Transmitting => ("", Style::default().fg(Color::Red)),
};
let title = format!("KAT v{}", VERSION);
// Build radio info string with all settings
let amp_str = if app.amp_enabled { "ON" } else { "OFF" };
let radio_info = format!(
"{} {} | {:.2} MHz | LNA:{} VGA:{} AMP:{}",
status_symbol,
app.radio_state,
app.frequency_mhz(),
app.lna_gain,
app.vga_gain,
amp_str
);
// Calculate padding for right-alignment
let padding = area
.width
.saturating_sub(title.len() as u16 + radio_info.len() as u16 + 4);
let header_line = Line::from(vec![
Span::styled(title, Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" ".repeat(padding as usize)),
Span::styled(radio_info, status_style),
]);
let header = Paragraph::new(header_line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default()),
);
frame.render_widget(header, area);
}
/// Render the context-sensitive help bar
fn render_help_bar(frame: &mut Frame, area: Rect, app: &App) {
let help_text = match app.input_mode {
InputMode::Normal => {
"Enter: Actions | Tab: Settings | r: RX Toggle | :: Command | q: Quit"
}
InputMode::Command => "Enter: Execute | Esc: Cancel",
InputMode::SignalMenu => "Up/Down: Navigate | Enter: Select | Esc: Close",
InputMode::SettingsSelect => "Left/Right: Select | Tab: Cycle | Enter: Edit | Esc: Back",
InputMode::SettingsEdit => "Up/Down: Change Value | Enter: Apply | Esc: Cancel",
InputMode::StartupImport => "y: Import | n: Skip",
InputMode::FobMetaYear
| InputMode::FobMetaMake
| InputMode::FobMetaModel
| InputMode::FobMetaRegion => "Enter: Next Field | Esc: Cancel Export",
InputMode::FobMetaNotes => "Enter: Save & Export | Esc: Cancel Export",
};
let help = Paragraph::new(Line::from(Span::styled(
format!(" {}", help_text),
Style::default().fg(Color::DarkGray),
)));
frame.render_widget(help, area);
}
/// Center a rect of given width/height in the given area
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect::new(x, y, width.min(area.width), height.min(area.height))
}
/// Render the startup import prompt overlay
fn render_startup_import_prompt(frame: &mut Frame, app: &App) {
let count = app.pending_fob_files.len();
let area = frame.area();
let popup = centered_rect(50, 7, area);
frame.render_widget(Clear, popup);
let text = vec![
Line::from(""),
Line::from(Span::styled(
format!("Found {} .fob file(s) in export directory.", count),
Style::default().fg(Color::Yellow),
)),
Line::from(""),
Line::from(Span::styled(
"Import them? (y/n)",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
];
let block = Block::default()
.title(" Import Saved Signals ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let paragraph = Paragraph::new(text)
.block(block)
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, popup);
}
/// Render the .fob export metadata form overlay with signal summary
fn render_fob_metadata_form(frame: &mut Frame, app: &App) {
let area = frame.area();
let popup = centered_rect(62, 19, area);
frame.render_widget(Clear, popup);
let active_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let inactive_style = Style::default().fg(Color::DarkGray);
let done_style = Style::default().fg(Color::Green);
let value_style = Style::default().fg(Color::White);
let dim_style = Style::default().fg(Color::DarkGray);
let accent_style = Style::default().fg(Color::Yellow);
let cursor = Span::styled(
"_",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::RAPID_BLINK),
);
// Determine which field is active
let field_modes = [
InputMode::FobMetaYear,
InputMode::FobMetaMake,
InputMode::FobMetaModel,
InputMode::FobMetaRegion,
InputMode::FobMetaNotes,
];
let current_idx = field_modes
.iter()
.position(|m| *m == app.input_mode)
.unwrap_or(0);
let style_for = |idx: usize| -> Style {
if idx == current_idx {
active_style
} else if idx < current_idx {
done_style
} else {
inactive_style
}
};
let mut lines = Vec::new();
// --- Signal summary section ---
if let Some(capture) = app
.export_capture_id
.and_then(|id| app.captures.iter().find(|c| c.id == id))
{
lines.push(Line::from(vec![
Span::styled(" Signal: ", dim_style),
Span::styled(
format!(
"#{:02} {} | {} | {} | 0x{}",
capture.id,
capture.protocol_name(),
capture.frequency_mhz(),
capture.modulation(),
capture.serial_hex(),
),
accent_style,
),
]));
lines.push(Line::from(vec![
Span::styled(" Key: ", dim_style),
Span::styled(
format!("0x{} ({})", capture.data_hex(), capture.encryption_type()),
accent_style,
),
]));
}
lines.push(Line::from(Span::styled(
" ──────────────────────────────────────────────────────",
dim_style,
)));
// --- Form fields ---
struct FormField<'a> {
label: &'a str,
value: &'a str,
placeholder: &'a str,
idx: usize,
}
let fields = [
FormField {
label: " Year: ",
value: &app.fob_meta_year,
placeholder: "(e.g. 2024)",
idx: 0,
},
FormField {
label: " Make: ",
value: &app.fob_meta_make,
placeholder: "(auto-detected from protocol)",
idx: 1,
},
FormField {
label: " Model: ",
value: &app.fob_meta_model,
placeholder: "(e.g. Sportage, F-150)",
idx: 2,
},
FormField {
label: " Region: ",
value: &app.fob_meta_region,
placeholder: "(e.g. NA, EU, APAC, MEA)",
idx: 3,
},
FormField {
label: " Notes: ",
value: &app.fob_meta_notes,
placeholder: "(optional — color, trim, VIN, etc.)",
idx: 4,
},
];
for field in &fields {
let label_s = style_for(field.idx);
let display_val = if field.value.is_empty() {
field.placeholder
} else {
field.value
};
let val_s = if field.value.is_empty() && field.idx != current_idx {
dim_style
} else {
value_style
};
let mut spans = vec![
Span::styled(field.label, label_s),
Span::styled(display_val.to_string(), val_s),
];
// Show cursor on active field
if field.idx == current_idx {
spans.push(cursor.clone());
}
// Show checkmark for completed fields with values
if field.idx < current_idx && !field.value.is_empty() {
spans.push(Span::styled("", done_style));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
// Progress indicator
let total_fields = fields.len();
let progress = format!(
" Step {}/{}",
current_idx + 1,
total_fields,
);
let hint = if current_idx == total_fields - 1 {
" Enter: Save & Export | Esc: Cancel"
} else {
" Enter: Next | Esc: Cancel"
};
lines.push(Line::from(vec![
Span::styled(progress, accent_style),
Span::styled(" ", dim_style),
Span::styled(hint, dim_style),
]));
let block = Block::default()
.title(" Export .fob — Vehicle Details ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, popup);
}
+10
View File
@@ -0,0 +1,10 @@
//! Terminal UI components.
mod captures_list;
mod command;
mod layout;
pub mod signal_menu;
pub mod settings_menu;
mod status_bar;
pub use layout::draw_ui;
+126
View File
@@ -0,0 +1,126 @@
//! Radio settings inline editor triggered by Tab.
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
use crate::app::{App, InputMode, SettingsField, PRESET_FREQUENCIES, LNA_STEPS, VGA_STEPS};
/// Render the settings selector tabs in the header area
pub fn render_settings_tabs(frame: &mut Frame, area: Rect, app: &App) {
let mut spans = Vec::new();
spans.push(Span::styled(" Settings: ", Style::default().fg(Color::DarkGray)));
for (i, field) in SettingsField::ALL.iter().enumerate() {
let is_selected = app.input_mode == InputMode::SettingsSelect
&& i == app.settings_field_index;
let is_editing = app.input_mode == InputMode::SettingsEdit
&& i == app.settings_field_index;
let style = if is_editing {
Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let value = match field {
SettingsField::Freq => format!("{:.2}MHz", app.frequency as f64 / 1_000_000.0),
SettingsField::Lna => format!("{}dB", app.lna_gain),
SettingsField::Vga => format!("{}dB", app.vga_gain),
SettingsField::Amp => if app.amp_enabled { "ON".to_string() } else { "OFF".to_string() },
};
spans.push(Span::styled(
format!(" [{}:{}] ", field.label(), value),
style,
));
}
let line = Line::from(spans);
let widget = Paragraph::new(line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" Radio Settings (Tab) "),
);
frame.render_widget(widget, area);
}
/// Render the settings value dropdown when in SettingsEdit mode
pub fn render_settings_dropdown(frame: &mut Frame, app: &App) {
if app.input_mode != InputMode::SettingsEdit {
return;
}
let area = frame.area();
let field = SettingsField::ALL[app.settings_field_index];
let values: Vec<String> = match field {
SettingsField::Freq => PRESET_FREQUENCIES
.iter()
.map(|(_, label)| label.to_string())
.collect(),
SettingsField::Lna => LNA_STEPS.iter().map(|g| format!("{} dB", g)).collect(),
SettingsField::Vga => VGA_STEPS.iter().map(|g| format!("{} dB", g)).collect(),
SettingsField::Amp => vec!["ON".to_string(), "OFF".to_string()],
};
let menu_width = 22u16;
let menu_height = (values.len() as u16) + 2; // items + borders
// Position: below the header, near the field
let x_offset = 12 + (app.settings_field_index as u16) * 16;
let x = x_offset.min(area.width.saturating_sub(menu_width));
let y = 3u16; // Below header
let menu_area = Rect::new(
x,
y,
menu_width.min(area.width.saturating_sub(x)),
menu_height.min(area.height.saturating_sub(y)),
);
frame.render_widget(Clear, menu_area);
let items: Vec<ListItem> = values
.iter()
.enumerate()
.map(|(i, val)| {
let style = if i == app.settings_value_index {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = if i == app.settings_value_index {
"> "
} else {
" "
};
ListItem::new(Line::from(Span::styled(
format!("{}{}", prefix, val),
style,
)))
})
.collect();
let list = List::new(items).block(
Block::default()
.title(format!(" {} ", field.label()))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green)),
);
frame.render_widget(list, menu_area);
}
+94
View File
@@ -0,0 +1,94 @@
//! Signal action popup menu rendered as a centered overlay.
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem},
Frame,
};
use crate::app::{App, SignalAction};
/// Render the signal action popup menu as a centered overlay
pub fn render_signal_menu(frame: &mut Frame, app: &App) {
let area = frame.area();
// Get selected capture info for the header
let (capture_info, freq_info) = if let Some(idx) = app.selected_capture {
if idx < app.captures.len() {
let c = &app.captures[idx];
(
format!("#{:02} {} | 0x{}", c.id, c.protocol_name(), c.serial_hex()),
format!("{} | {}", c.frequency_mhz(), c.modulation()),
)
} else {
("No capture".to_string(), String::new())
}
} else {
("No capture".to_string(), String::new())
};
// Menu dimensions - wider to show more info
let menu_width = 38u16;
let extra_lines = if freq_info.is_empty() { 0u16 } else { 2u16 };
let menu_height = (SignalAction::ALL.len() as u16) + 4 + extra_lines;
// Center the menu
let x = area.width.saturating_sub(menu_width) / 2;
let y = area.height.saturating_sub(menu_height) / 2;
let menu_area = Rect::new(x, y, menu_width.min(area.width), menu_height.min(area.height));
// Clear the area behind the popup
frame.render_widget(Clear, menu_area);
// Build list items
let mut items: Vec<ListItem> = Vec::new();
// Add signal info lines at the top if we have capture data
if !freq_info.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(
format!(" {}", freq_info),
Style::default().fg(Color::DarkGray),
))));
items.push(ListItem::new(Line::from(Span::raw(""))));
}
// Add action items
for (i, action) in SignalAction::ALL.iter().enumerate() {
let style = if i == app.signal_menu_index {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
match action {
SignalAction::Delete => Style::default().fg(Color::Red),
SignalAction::ExportFob | SignalAction::ExportFlipper => {
Style::default().fg(Color::Green)
}
_ => Style::default().fg(Color::White),
}
};
let prefix = if i == app.signal_menu_index {
" > "
} else {
" "
};
items.push(ListItem::new(Line::from(Span::styled(
format!("{}{}", prefix, action.label()),
style,
))));
}
let list = List::new(items).block(
Block::default()
.title(format!(" {} ", capture_info))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
);
frame.render_widget(list, menu_area);
}
+38
View File
@@ -0,0 +1,38 @@
//! Status bar widget.
use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
/// Render the status bar with messages and errors
pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App) {
let (message, style) = if let Some(ref error) = app.last_error {
(
format!("Error: {}", error),
Style::default().fg(Color::Red),
)
} else if let Some(ref status) = app.status_message {
(status.clone(), Style::default().fg(Color::Green))
} else {
(
format!("Captures: {}", app.captures.len()),
Style::default().fg(Color::DarkGray),
)
};
let status_line = Line::from(vec![Span::styled(message, style)]);
let status = Paragraph::new(status_line).block(
Block::default()
.borders(Borders::ALL)
.title("status"),
);
frame.render_widget(status, area);
}