Scripts and added :load function
This commit is contained in:
+118
@@ -1,6 +1,7 @@
|
||||
//! Application state management.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
@@ -135,6 +136,8 @@ pub enum InputMode {
|
||||
License,
|
||||
/// Credits overlay (centered box)
|
||||
Credits,
|
||||
/// :load file browser (import .fob/.sub from import dir)
|
||||
LoadFileBrowser,
|
||||
}
|
||||
|
||||
/// Export format being used
|
||||
@@ -360,6 +363,16 @@ pub struct App {
|
||||
pub capture_meta_region: String,
|
||||
/// Which capture is being edited (when in CaptureMeta* modes)
|
||||
pub capture_meta_capture_id: Option<u32>,
|
||||
|
||||
// -- :load file browser --
|
||||
/// Current directory in the load file browser
|
||||
pub load_browser_cwd: PathBuf,
|
||||
/// Selected index in the file list
|
||||
pub load_browser_selected: usize,
|
||||
/// Scroll offset for the file list (so selection stays in view)
|
||||
pub load_browser_scroll: usize,
|
||||
/// Entries: (display name, full path, is_dir)
|
||||
pub load_browser_entries: Vec<(String, PathBuf, bool)>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -469,6 +482,10 @@ impl App {
|
||||
capture_meta_model: String::new(),
|
||||
capture_meta_region: String::new(),
|
||||
capture_meta_capture_id: None,
|
||||
load_browser_cwd: PathBuf::new(),
|
||||
load_browser_selected: 0,
|
||||
load_browser_scroll: 0,
|
||||
load_browser_entries: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -599,6 +616,9 @@ impl App {
|
||||
self.input_mode = InputMode::Credits;
|
||||
self.overlay_scroll = 0;
|
||||
}
|
||||
"load" => {
|
||||
self.open_load_browser()?;
|
||||
}
|
||||
"delete" => {
|
||||
if parts.len() < 2 {
|
||||
self.last_error = Some("Usage: :delete <ID> or :delete all".to_string());
|
||||
@@ -1246,6 +1266,104 @@ impl App {
|
||||
self.capture_meta_capture_id = None;
|
||||
}
|
||||
|
||||
/// Open the :load file browser starting at the config import directory.
|
||||
pub fn open_load_browser(&mut self) -> Result<()> {
|
||||
self.load_browser_cwd = self.storage.import_dir().clone();
|
||||
self.load_browser_selected = 0;
|
||||
self.refresh_load_browser_entries()?;
|
||||
self.input_mode = InputMode::LoadFileBrowser;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh the file list for the current load-browser directory.
|
||||
pub fn refresh_load_browser_entries(&mut self) -> Result<()> {
|
||||
let import_dir = self.storage.import_dir().clone();
|
||||
let mut entries: Vec<(String, PathBuf, bool)> = Vec::new();
|
||||
|
||||
if self.load_browser_cwd != import_dir {
|
||||
if let Some(parent) = self.load_browser_cwd.parent() {
|
||||
entries.push(("..".to_string(), parent.to_path_buf(), true));
|
||||
}
|
||||
}
|
||||
|
||||
let dir_entries = match std::fs::read_dir(&self.load_browser_cwd) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
self.last_error = Some(format!("Cannot read directory: {}", e));
|
||||
self.load_browser_entries = entries;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let mut dirs: Vec<(String, PathBuf)> = Vec::new();
|
||||
let mut files: Vec<(String, PathBuf)> = Vec::new();
|
||||
for e in dir_entries.flatten() {
|
||||
let path = e.path();
|
||||
let name = e
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if path.is_dir() {
|
||||
dirs.push((name, path));
|
||||
} else if path.is_file() {
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_lowercase());
|
||||
if ext.as_deref() == Some("fob") || ext.as_deref() == Some("sub") {
|
||||
files.push((name, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dirs.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
|
||||
files.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
|
||||
|
||||
for (name, path) in dirs {
|
||||
entries.push((name, path, true));
|
||||
}
|
||||
for (name, path) in files {
|
||||
entries.push((name, path, false));
|
||||
}
|
||||
|
||||
let len = entries.len();
|
||||
self.load_browser_entries = entries;
|
||||
self.load_browser_selected = self.load_browser_selected.min(len.saturating_sub(1));
|
||||
const VISIBLE: usize = 16;
|
||||
if self.load_browser_selected < self.load_browser_scroll {
|
||||
self.load_browser_scroll = self.load_browser_selected;
|
||||
}
|
||||
if self.load_browser_selected >= self.load_browser_scroll + VISIBLE {
|
||||
self.load_browser_scroll = self.load_browser_selected.saturating_sub(VISIBLE - 1);
|
||||
}
|
||||
self.load_browser_scroll = self.load_browser_scroll.min(len.saturating_sub(1));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Enter in the load file browser: open dir or import file.
|
||||
pub fn load_browser_enter(&mut self) -> Result<()> {
|
||||
let Some((_name, path, is_dir)) = self.load_browser_entries.get(self.load_browser_selected)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let path = path.clone();
|
||||
let is_dir = *is_dir;
|
||||
if is_dir {
|
||||
self.load_browser_cwd = path;
|
||||
self.load_browser_selected = 0;
|
||||
self.refresh_load_browser_entries()?;
|
||||
} else {
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
self.pending_fob_files = vec![path];
|
||||
self.import_fob_files()?;
|
||||
self.input_mode = InputMode::Normal;
|
||||
self.status_message = Some(format!("Imported {}", name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the load file browser without importing.
|
||||
pub fn close_load_browser(&mut self) {
|
||||
self.input_mode = InputMode::Normal;
|
||||
}
|
||||
|
||||
/// Import pending .fob and .sub files into captures list.
|
||||
/// .sub files are decoded with registered protocols after load (no metadata in file).
|
||||
/// When research_mode is off, only decoded captures are added (same as live capture).
|
||||
|
||||
+30
@@ -481,6 +481,36 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
|
||||
_ => {}
|
||||
},
|
||||
|
||||
InputMode::LoadFileBrowser => {
|
||||
const VISIBLE: usize = 16;
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.close_load_browser();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let _ = app.load_browser_enter();
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if app.load_browser_selected > 0 {
|
||||
app.load_browser_selected -= 1;
|
||||
if app.load_browser_selected < app.load_browser_scroll {
|
||||
app.load_browser_scroll = app.load_browser_selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let max = app.load_browser_entries.len().saturating_sub(1);
|
||||
if app.load_browser_selected < max {
|
||||
app.load_browser_selected += 1;
|
||||
if app.load_browser_selected >= app.load_browser_scroll + VISIBLE {
|
||||
app.load_browser_scroll = app.load_browser_selected - VISIBLE + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
|
||||
InputMode::License | InputMode::Credits => match key.code {
|
||||
KeyCode::Esc | KeyCode::Enter => {
|
||||
app.input_mode = InputMode::Normal;
|
||||
|
||||
@@ -66,6 +66,11 @@ pub fn render_command_line(frame: &mut Frame, area: Rect, app: &App) {
|
||||
"META",
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
InputMode::LoadFileBrowser => (
|
||||
String::new(),
|
||||
"LOAD",
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
InputMode::License => (
|
||||
String::new(),
|
||||
"LICENSE",
|
||||
|
||||
+74
-1
@@ -4,7 +4,7 @@ use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
@@ -120,6 +120,10 @@ pub fn draw_ui(frame: &mut Frame, app: &App) {
|
||||
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);
|
||||
}
|
||||
@@ -244,6 +248,7 @@ fn render_help_bar(frame: &mut Frame, area: Rect, app: &App) {
|
||||
| InputMode::CaptureMetaModel => "Enter: Next Field | Esc: Cancel",
|
||||
InputMode::CaptureMetaRegion => "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(
|
||||
@@ -261,6 +266,74 @@ fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user