Version 1.0.0
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user