Files
KAT/src/ui/layout.rs
T

811 lines
27 KiB
Rust

//! Main UI layout.
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
Frame,
};
use crate::app::{App, InputMode, RadioState, LICENSE_TEXT};
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 super::text_overlay::render_text_overlay;
use crate::app::InputMode as IM;
const VERSION: &str = env!("CARGO_PKG_VERSION");
/// RSSI bar width (right side)
const RSSI_BAR_WIDTH: u16 = 5;
/// 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;
// Full-width rows: header, settings (optional), then middle row split into [captures | RX bar], status, command (optional), help
let main_area = frame.area();
let mut v_constraints = vec![
Constraint::Length(3), // Header (full width)
Constraint::Min(26), // Middle: captures table + detail panel (signal + vulnerability)
Constraint::Length(3), // Status bar (full width)
Constraint::Length(1), // Help bar (full width)
];
if show_settings {
v_constraints.insert(1, Constraint::Length(3)); // Settings tabs (full width)
}
if show_command {
v_constraints.insert(v_constraints.len() - 1, Constraint::Length(3)); // Command (full width)
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(v_constraints)
.split(main_area);
let mut idx = 0;
render_header(frame, rows[idx], app);
idx += 1;
if show_settings {
render_settings_tabs(frame, rows[idx], app);
idx += 1;
}
// Only the middle row is split: captures (left) | RX bar (right)
let middle_row = rows[idx];
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(RSSI_BAR_WIDTH)])
.split(middle_row);
let captures_area = h_chunks[0];
let rssi_bar_rect = h_chunks[1];
idx += 1;
render_captures_list(frame, captures_area, app);
render_rssi_bar(frame, rssi_bar_rect, app);
render_status_bar(frame, rows[idx], app);
idx += 1;
if show_command {
render_command_line(frame, rows[idx], app);
idx += 1;
}
render_help_bar(frame, rows[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::HackRfNotDetected {
render_hackrf_not_detected(frame, app);
}
if app.input_mode == InputMode::StartupImport {
render_startup_import_prompt(frame, app);
}
if matches!(
app.input_mode,
InputMode::ExportFilename
| InputMode::FobMetaYear
| InputMode::FobMetaMake
| InputMode::FobMetaModel
| InputMode::FobMetaRegion
| InputMode::FobMetaCommand
| InputMode::FobMetaNotes
) {
render_export_form(frame, app);
}
if matches!(
app.input_mode,
InputMode::CaptureMetaYear
| InputMode::CaptureMetaMake
| InputMode::CaptureMetaModel
| InputMode::CaptureMetaRegion
| InputMode::CaptureMetaCommand
) {
render_capture_meta_form(frame, app);
}
if app.input_mode == InputMode::LoadFileBrowser {
render_load_file_browser(frame, app);
}
if app.input_mode == InputMode::License {
render_text_overlay(frame, app, "License", LICENSE_TEXT, Alignment::Left);
}
if app.input_mode == InputMode::Credits {
render_text_overlay(
frame,
app,
"Credits",
super::text_overlay::CREDITS_TEXT,
Alignment::Center,
);
}
}
/// Render the RSSI bar on the right (vertical bar, bottom = strong). Shows " TX " in red when transmitting.
fn render_rssi_bar(frame: &mut Frame, area: Rect, app: &App) {
let is_tx = app.radio_state == RadioState::Transmitting;
let (title, filled_style, empty_style) = if is_tx {
(" TX ", Style::default().fg(Color::Red), Style::default().fg(Color::DarkGray))
} else {
(" RX ", Style::default().fg(Color::Green), Style::default().fg(Color::DarkGray))
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(if is_tx { Style::default().fg(Color::Red) } else { Style::default() })
.title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
// When TX: show full red bar. When RX: scale RSSI to fill ratio
let (fill_ratio, style) = if is_tx {
(1.0_f32, filled_style)
} else {
let fill_ratio = (app.rssi / 0.6).min(1.0);
(fill_ratio, filled_style)
};
let filled_rows = (inner.height as f32 * fill_ratio).round() as u16;
let mut lines = Vec::with_capacity(inner.height as usize);
for r in 0..inner.height {
let fill = r >= inner.height.saturating_sub(filled_rows);
let (s, line_style) = if fill {
("█".repeat(inner.width as usize), style)
} else {
(" ".repeat(inner.width as usize), empty_style)
};
lines.push(Line::from(Span::styled(s, line_style)));
}
let paragraph = Paragraph::new(Text::from(lines));
frame.render_widget(paragraph, inner);
}
/// 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::Red)),
RadioState::Idle => ("○", Style::default().fg(Color::Yellow)),
RadioState::Receiving => ("●", Style::default().fg(Color::Green)),
RadioState::Transmitting => ("●", Style::default().fg(Color::Red)),
};
let title = format!("Keyfob Analysis Toolkit v{}", VERSION);
// Build radio info string: device name (if any), state, freq, gains
let amp_str = if app.amp_enabled { "ON" } else { "OFF" };
let device_str = app
.radio_device_name()
.unwrap_or("No device");
let radio_info = format!(
"{} {} | {} | {:.2} MHz | LNA:{} VGA:{} AMP:{}",
status_symbol,
app.radio_state,
device_str,
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 | d: Delete | 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::HackRfNotDetected => "Press any key to continue",
InputMode::StartupImport => "y: Import | n: Skip",
InputMode::ExportFilename => {
match app.export_format {
Some(crate::app::ExportFormat::Fob) => "Enter: Next Field | Esc: Cancel Export",
Some(crate::app::ExportFormat::Flipper) => "Enter: Save & Export | Esc: Cancel Export",
None => "Enter: Confirm | Esc: Cancel",
}
}
InputMode::FobMetaYear
| InputMode::FobMetaMake
| InputMode::FobMetaModel
| InputMode::FobMetaRegion
| InputMode::FobMetaCommand => "Enter: Next Field | Esc: Cancel Export",
InputMode::FobMetaNotes => "Enter: Save & Export | Esc: Cancel Export",
InputMode::CaptureMetaYear
| InputMode::CaptureMetaMake
| InputMode::CaptureMetaModel
| InputMode::CaptureMetaRegion => "Enter: Next Field | Esc: Cancel",
InputMode::CaptureMetaCommand => "Enter: Save | Esc: Cancel",
InputMode::License | InputMode::Credits => "Esc/Enter: Close | Up/Down: Scroll",
InputMode::LoadFileBrowser => "Up/Down: Navigate | Enter: Open/Import | Esc: Close",
};
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))
}
const LOAD_BROWSER_VISIBLE_ROWS: usize = 16;
/// Render the :load file browser overlay (centered, list of dirs and .fob/.sub files).
fn render_load_file_browser(frame: &mut Frame, app: &App) {
let area = frame.area();
let popup_height = LOAD_BROWSER_VISIBLE_ROWS as u16 + 5;
let popup_width = 56;
let popup = centered_rect(popup_width, popup_height, area);
frame.render_widget(Clear, popup);
let path_str = app.load_browser_cwd.to_string_lossy();
let path_display = if path_str.len() > popup_width as usize - 4 {
format!("..{}", &path_str[path_str.len().saturating_sub(popup_width as usize - 5)..])
} else {
path_str.to_string()
};
let mut items: Vec<ListItem> = Vec::new();
items.push(ListItem::new(Line::from(Span::styled(
format!(" {}", path_display),
Style::default().fg(Color::DarkGray),
))));
items.push(ListItem::new(Line::from(Span::raw(""))));
let entries = &app.load_browser_entries;
let scroll = app.load_browser_scroll;
let selected = app.load_browser_selected.min(entries.len().saturating_sub(1));
let end = (scroll + LOAD_BROWSER_VISIBLE_ROWS).min(entries.len());
for (i, (name, _path, is_dir)) in entries[scroll..end].iter().enumerate() {
let idx = scroll + i;
let is_selected = idx == selected;
let prefix = if is_selected { " > " } else { " " };
let (style, suffix) = if *is_dir {
(
if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
},
"/",
)
} else {
(
if is_selected {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
},
"",
)
};
items.push(ListItem::new(Line::from(Span::styled(
format!("{}{}{}", prefix, name, suffix),
style,
))));
}
let block = Block::default()
.title(" Load file (.fob / .sub) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let list = List::new(items).block(block);
frame.render_widget(list, popup);
}
/// Render the no-device warning (red box at startup when neither HackRF nor RTL-SDR found)
fn render_hackrf_not_detected(frame: &mut Frame, _app: &App) {
let area = frame.area();
let popup = centered_rect(56, 8, area);
frame.render_widget(Clear, popup);
let red = Color::Red;
let text = vec![
Line::from(""),
Line::from(Span::styled(
" No HackRF or RTL-SDR detected.",
Style::default().fg(red).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
" Connect a HackRF (TX+RX) or RTL-SDR (RX only) and restart,",
Style::default().fg(red),
)),
Line::from(Span::styled(
" or continue without TX/RX support.",
Style::default().fg(red),
)),
Line::from(""),
Line::from(Span::styled(
" Press any key to continue.",
Style::default().fg(Color::White),
)),
];
let block = Block::default()
.title(" Warning ")
.borders(Borders::ALL)
.border_style(Style::default().fg(red))
.style(Style::default().fg(red));
let paragraph = Paragraph::new(text)
.block(block)
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, popup);
}
/// 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 {} file(s) (.fob / .sub) in import dir (incl. subfolders).",
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 export form overlay (filename + optional .fob metadata)
fn render_export_form(frame: &mut Frame, app: &App) {
use crate::app::ExportFormat;
let is_fob = app.export_format == Some(ExportFormat::Fob);
let ext = if is_fob { ".fob" } else { ".sub" };
let area = frame.area();
// Taller popup for .fob (filename + 5 metadata fields), shorter for .sub (filename only)
let popup_height = if is_fob { 21 } else { 11 };
let popup = centered_rect(62, popup_height, 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),
);
// Build the ordered list of field modes for this export type
// Filename is always first; .fob adds metadata fields after
let field_modes: Vec<InputMode> = if is_fob {
vec![
InputMode::ExportFilename,
InputMode::FobMetaYear,
InputMode::FobMetaMake,
InputMode::FobMetaModel,
InputMode::FobMetaRegion,
InputMode::FobMetaCommand,
InputMode::FobMetaNotes,
]
} else {
vec![InputMode::ExportFilename]
};
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,
}
// Filename field (always present)
let filename_display = format!("{}{}", app.export_filename, ext);
let mut fields: Vec<FormField> = vec![
FormField {
label: " File: ",
value: &filename_display,
placeholder: "(enter filename)",
idx: 0,
},
];
// .fob metadata fields
if is_fob {
fields.extend([
FormField {
label: " Year: ",
value: &app.fob_meta_year,
placeholder: "(e.g. 2024)",
idx: 1,
},
FormField {
label: " Make: ",
value: &app.fob_meta_make,
placeholder: "(auto-detected from protocol)",
idx: 2,
},
FormField {
label: " Model: ",
value: &app.fob_meta_model,
placeholder: "(e.g. Sportage, F-150)",
idx: 3,
},
FormField {
label: " Region: ",
value: &app.fob_meta_region,
placeholder: "(e.g. NA, EU, APAC, MEA)",
idx: 4,
},
FormField {
label: " Command: ",
value: &app.fob_meta_command,
placeholder: "(e.g. Unlock, Lock, Trunk, Panic)",
idx: 5,
},
FormField {
label: " Notes: ",
value: &app.fob_meta_notes,
placeholder: "(optional — color, trim, VIN, etc.)",
idx: 6,
},
]);
}
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 title = if is_fob {
" Export .fob — Filename & Vehicle Details "
} else {
" Export .sub (Flipper Zero) "
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, popup);
}
/// Render the capture metadata form (Year/Make/Model/Region for vuln lookup). Shown when user presses 'i' on a capture.
fn render_capture_meta_form(frame: &mut Frame, app: &App) {
let area = frame.area();
let popup = centered_rect(62, 18, 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),
);
let field_modes = [
InputMode::CaptureMetaYear,
InputMode::CaptureMetaMake,
InputMode::CaptureMetaModel,
InputMode::CaptureMetaRegion,
InputMode::CaptureMetaCommand,
];
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();
if let Some(capture) = app
.capture_meta_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,
)));
struct FormField<'a> {
label: &'a str,
value: &'a str,
placeholder: &'a str,
idx: usize,
}
let fields: [FormField; 5] = [
FormField {
label: " Year: ",
value: &app.capture_meta_year,
placeholder: "(e.g. 2021)",
idx: 0,
},
FormField {
label: " Make: ",
value: &app.capture_meta_make,
placeholder: "(e.g. Renault, Honda)",
idx: 1,
},
FormField {
label: " Model: ",
value: &app.capture_meta_model,
placeholder: "(e.g. ZOE, Civic — or ALL)",
idx: 2,
},
FormField {
label: " Region: ",
value: &app.capture_meta_region,
placeholder: "(e.g. NA, EU, or ALL)",
idx: 3,
},
FormField {
label: " Command: ",
value: &app.capture_meta_command,
placeholder: "(e.g. Unlock, Lock, Trunk, Panic)",
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),
];
if field.idx == current_idx {
spans.push(cursor.clone());
}
if field.idx < current_idx && !field.value.is_empty() {
spans.push(Span::styled(" ✓", done_style));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
let progress = format!(" Step {}/4 — Used for vuln lookup and .fob export", current_idx + 1);
lines.push(Line::from(Span::styled(progress, dim_style)));
let block = Block::default()
.title(" Capture Metadata — Year / Make / Model / Region ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, popup);
}