//! 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::ExportFilename | InputMode::FobMetaYear | InputMode::FobMetaMake | InputMode::FobMetaModel | InputMode::FobMetaRegion | InputMode::FobMetaNotes ) { render_export_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::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 => "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 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 = if is_fob { vec![ InputMode::ExportFilename, InputMode::FobMetaYear, InputMode::FobMetaMake, InputMode::FobMetaModel, InputMode::FobMetaRegion, 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 = 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: " Notes: ", value: &app.fob_meta_notes, placeholder: "(optional — color, trim, VIN, etc.)", idx: 5, }, ]); } 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); }