|
@@ -0,0 +1,2870 @@
|
|
|
|
|
+use crossterm::{
|
|
|
|
|
+ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind},
|
|
|
|
|
+ execute,
|
|
|
|
|
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
|
|
|
+};
|
|
|
|
|
+use ratatui::{
|
|
|
|
|
+ backend::CrosstermBackend,
|
|
|
|
|
+ layout::{Constraint, Direction, Layout, Rect},
|
|
|
|
|
+ style::{Color, Modifier, Style},
|
|
|
|
|
+ text::{Line, Span},
|
|
|
|
|
+ widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
|
|
|
|
|
+ Frame, Terminal,
|
|
|
|
|
+};
|
|
|
|
|
+use std::io::{self, Write};
|
|
|
|
|
+use std::path::{Path, PathBuf};
|
|
|
|
|
+use std::time::{Duration, Instant};
|
|
|
|
|
+
|
|
|
|
|
+use crate::cli::Args;
|
|
|
|
|
+use crate::config::Config;
|
|
|
|
|
+use crate::config::FavoriteEntry;
|
|
|
|
|
+use crate::lookup;
|
|
|
|
|
+use crate::tlds::{apply_top_tlds, get_tlds_or_default, list_names, default_list_name};
|
|
|
|
|
+use crate::types::{DomainResult, DomainStatus, ErrorKind};
|
|
|
|
|
+
|
|
|
|
|
+// note : this will be the worst shitshow of code you will probably have looked at in youre entire life
|
|
|
|
|
+// it works and is somewhat stable but i didnt feel like sorting it into nice modules and all.
|
|
|
|
|
+// have fun
|
|
|
|
|
+
|
|
|
|
|
+// names and labels
|
|
|
|
|
+const APP_NAME: &str = "hoardom";
|
|
|
|
|
+const APP_DESC: &str = "Domain hoarding made less painful";
|
|
|
|
|
+const CLOSE_BUTTON_LABEL: &str = "[X]";
|
|
|
|
|
+const EXPORT_BUTTON_LABEL: &str = "[Export](F2)";
|
|
|
|
|
+const HELP_BUTTON_LABEL: &str = "[Help](F1)";
|
|
|
|
|
+const SEARCH_BUTTON_LABEL: &str = "[Search]";
|
|
|
|
|
+const STOP_BUTTON_LABEL: &str = "[Stop](s)";
|
|
|
|
|
+const CLEAR_BUTTON_LABEL: &str = "[Clear](C)";
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+// Layout tuning constants
|
|
|
|
|
+const TOPBAR_HEIGHT: u16 = 1;
|
|
|
|
|
+const SEARCH_PANEL_HEIGHT: u16 = 3;
|
|
|
|
|
+const CONTENT_MIN_HEIGHT: u16 = 5;
|
|
|
|
|
+
|
|
|
|
|
+const SIDEBAR_TARGET_WIDTH_PERCENT: u16 = 30;
|
|
|
|
|
+const SIDEBAR_MIN_WIDTH: u16 = 24;
|
|
|
|
|
+const SIDEBAR_MAX_WIDTH: u16 = 26;
|
|
|
|
|
+
|
|
|
|
|
+const SCRATCHPAD_TARGET_WIDTH_PERCENT: u16 = 30;
|
|
|
|
|
+const SCRATCHPAD_MIN_WIDTH: u16 = 20;
|
|
|
|
|
+const SCRATCHPAD_MAX_WIDTH: u16 = 32;
|
|
|
|
|
+
|
|
|
|
|
+const RESULTS_MIN_WIDTH: u16 = 24;
|
|
|
|
|
+
|
|
|
|
|
+const FAVORITES_MIN_HEIGHT: u16 = 4;
|
|
|
|
|
+const SETTINGS_PANEL_HEIGHT: u16 = 8;
|
|
|
|
|
+const SETTINGS_PANEL_MIN_HEIGHT: u16 = SETTINGS_PANEL_HEIGHT;
|
|
|
|
|
+const SETTINGS_PANEL_MAX_HEIGHT: u16 = SETTINGS_PANEL_HEIGHT;
|
|
|
|
|
+
|
|
|
|
|
+const DROPDOWN_MAX_WIDTH: u16 = 26;
|
|
|
|
|
+const DROPDOWN_MAX_HEIGHT: u16 = 10;
|
|
|
|
|
+const EXPORT_POPUP_WIDTH: u16 = 82;
|
|
|
|
|
+const EXPORT_POPUP_HEIGHT: u16 = 10;
|
|
|
|
|
+
|
|
|
|
|
+const MIN_UI_WIDTH: u16 = SIDEBAR_MIN_WIDTH + RESULTS_MIN_WIDTH + SCRATCHPAD_MIN_WIDTH;
|
|
|
|
|
+const MIN_UI_HEIGHT: u16 = TOPBAR_HEIGHT + SEARCH_PANEL_HEIGHT + CONTENT_MIN_HEIGHT + 5;
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Debug, Clone, PartialEq)]
|
|
|
|
|
+enum Focus {
|
|
|
|
|
+ Search,
|
|
|
|
|
+ Scratchpad,
|
|
|
|
|
+ Results,
|
|
|
|
|
+ Favorites,
|
|
|
|
|
+ Settings,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn escape_csv(value: &str) -> String {
|
|
|
|
|
+ if value.contains([',', '"', '\n']) {
|
|
|
|
|
+ format!("\"{}\"", value.replace('"', "\"\""))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ value.to_string()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn export_favorites_txt(path: &Path, favorites: &[FavoriteEntry]) -> Result<(), String> {
|
|
|
|
|
+ if let Some(parent) = path.parent() {
|
|
|
|
|
+ std::fs::create_dir_all(parent)
|
|
|
|
|
+ .map_err(|e| format!("Failed to create export directory: {}", e))?;
|
|
|
|
|
+ }
|
|
|
|
|
+ let text: Vec<&str> = favorites.iter().map(|f| f.domain.as_str()).collect();
|
|
|
|
|
+ std::fs::write(path, text.join("\n"))
|
|
|
|
|
+ .map_err(|e| format!("Failed to export favorites: {}", e))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn export_results_csv(path: &Path, results: &[&DomainResult]) -> Result<(), String> {
|
|
|
|
|
+ if let Some(parent) = path.parent() {
|
|
|
|
|
+ std::fs::create_dir_all(parent)
|
|
|
|
|
+ .map_err(|e| format!("Failed to create export directory: {}", e))?;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let mut lines = vec!["domain,status,details".to_string()];
|
|
|
|
|
+ for result in results {
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ "{},{},{}",
|
|
|
|
|
+ escape_csv(&result.full),
|
|
|
|
|
+ escape_csv(result.status_str()),
|
|
|
|
|
+ escape_csv(&result.note_str()),
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ std::fs::write(path, lines.join("\n"))
|
|
|
|
|
+ .map_err(|e| format!("Failed to export results: {}", e))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Debug, Clone, PartialEq)]
|
|
|
|
|
+enum DropdownState {
|
|
|
|
|
+ Closed,
|
|
|
|
|
+ Open(usize), // which option is highlighted
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
+enum ExportMode {
|
|
|
|
|
+ FavoritesTxt,
|
|
|
|
|
+ ResultsCsv,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl ExportMode {
|
|
|
|
|
+
|
|
|
|
|
+ fn default_file_name(self) -> &'static str {
|
|
|
|
|
+ match self {
|
|
|
|
|
+ ExportMode::FavoritesTxt => "hoardom-favorites.txt",
|
|
|
|
|
+ ExportMode::ResultsCsv => "hoardom-results.csv",
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn toggled(self) -> Self {
|
|
|
|
|
+ match self {
|
|
|
|
|
+ ExportMode::FavoritesTxt => ExportMode::ResultsCsv,
|
|
|
|
|
+ ExportMode::ResultsCsv => ExportMode::FavoritesTxt,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Debug, Clone)]
|
|
|
|
|
+struct ExportPopup {
|
|
|
|
|
+ mode: ExportMode,
|
|
|
|
|
+ selected_row: usize,
|
|
|
|
|
+ path: String,
|
|
|
|
|
+ cursor_pos: usize,
|
|
|
|
|
+ status: Option<String>,
|
|
|
|
|
+ status_success: bool,
|
|
|
|
|
+ confirm_overwrite: bool,
|
|
|
|
|
+ close_at: Option<Instant>,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+struct App {
|
|
|
|
|
+ search_input: String,
|
|
|
|
|
+ cursor_pos: usize,
|
|
|
|
|
+ results: Vec<(usize, DomainResult)>,
|
|
|
|
|
+ results_state: ListState,
|
|
|
|
|
+ favorites: Vec<FavoriteEntry>,
|
|
|
|
|
+ favorites_state: ListState,
|
|
|
|
|
+ focus: Focus,
|
|
|
|
|
+ show_unavailable: bool,
|
|
|
|
|
+ clear_on_search: bool,
|
|
|
|
|
+ tld_list_name: String,
|
|
|
|
|
+ settings_selected: Option<usize>,
|
|
|
|
|
+ show_notes_panel: bool,
|
|
|
|
|
+ last_fav_export_path: String,
|
|
|
|
|
+ last_res_export_path: String,
|
|
|
|
|
+ scratchpad: String,
|
|
|
|
|
+ scratchpad_cursor: usize,
|
|
|
|
|
+ dropdown: DropdownState,
|
|
|
|
|
+ searching: bool,
|
|
|
|
|
+ search_progress: (usize, usize),
|
|
|
|
|
+ search_started_at: Option<Instant>,
|
|
|
|
|
+ last_search_duration: Option<Duration>,
|
|
|
|
|
+ status_msg: Option<String>,
|
|
|
|
|
+ config_path: PathBuf,
|
|
|
|
|
+ can_save: bool,
|
|
|
|
|
+ cache_path: Option<PathBuf>,
|
|
|
|
|
+ force_refresh: bool,
|
|
|
|
|
+ mouse_enabled: bool,
|
|
|
|
|
+ top_tlds: Option<Vec<String>>,
|
|
|
|
|
+ only_top: Option<Vec<String>>,
|
|
|
|
|
+ imported_lists: Vec<crate::config::ImportedFilter>,
|
|
|
|
|
+ cache_settings: crate::config::CacheSettings,
|
|
|
|
|
+ verbose: bool,
|
|
|
|
|
+ delay: f64,
|
|
|
|
|
+ retries: u32,
|
|
|
|
|
+ jobs: u8,
|
|
|
|
|
+ panel_rects: PanelRects, // updated each frame for mouse hit detection
|
|
|
|
|
+ stream_rx: Option<tokio::sync::mpsc::Receiver<lookup::StreamMsg>>,
|
|
|
|
|
+ stream_task: Option<tokio::task::JoinHandle<()>>,
|
|
|
|
|
+ patch: crate::tlds::WhoisOverrides,
|
|
|
|
|
+ noretry: Vec<ErrorKind>,
|
|
|
|
|
+ should_quit: bool,
|
|
|
|
|
+ show_help: bool,
|
|
|
|
|
+ export_popup: Option<ExportPopup>,
|
|
|
|
|
+ fav_check_rx: Option<tokio::sync::mpsc::Receiver<lookup::StreamMsg>>,
|
|
|
|
|
+ fav_check_task: Option<tokio::task::JoinHandle<()>>,
|
|
|
|
|
+ checking_favorites: bool,
|
|
|
|
|
+ backups_enabled: bool,
|
|
|
|
|
+ backup_count: u32,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Debug, Clone, Default)]
|
|
|
|
|
+struct PanelRects {
|
|
|
|
|
+ topbar: Option<Rect>,
|
|
|
|
|
+ export_button: Option<Rect>,
|
|
|
|
|
+ help_button: Option<Rect>,
|
|
|
|
|
+ help_popup: Option<Rect>,
|
|
|
|
|
+ dropdown: Option<Rect>,
|
|
|
|
|
+ search: Option<Rect>,
|
|
|
|
|
+ search_button: Option<Rect>,
|
|
|
|
|
+ cancel_button: Option<Rect>,
|
|
|
|
|
+ clear_button: Option<Rect>,
|
|
|
|
|
+ export_popup: Option<Rect>,
|
|
|
|
|
+ export_mode_favorites: Option<Rect>,
|
|
|
|
|
+ export_mode_results: Option<Rect>,
|
|
|
|
|
+ export_path: Option<Rect>,
|
|
|
|
|
+ export_cancel: Option<Rect>,
|
|
|
|
|
+ export_save: Option<Rect>,
|
|
|
|
|
+ scratchpad: Option<Rect>,
|
|
|
|
|
+ results: Option<Rect>,
|
|
|
|
|
+ favorites: Option<Rect>,
|
|
|
|
|
+ fav_check_button: Option<Rect>,
|
|
|
|
|
+ settings: Option<Rect>,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl App {
|
|
|
|
|
+ fn new(args: &Args, config: &Config, config_path: PathBuf, can_save: bool, cache_path: Option<PathBuf>, force_refresh: bool, whois_overrides: crate::tlds::WhoisOverrides, noretry: Vec<ErrorKind>) -> Self {
|
|
|
|
|
+ let tld_list_name = args
|
|
|
|
|
+ .tld_list
|
|
|
|
|
+ .as_ref()
|
|
|
|
|
+ .map(|s| s.to_lowercase())
|
|
|
|
|
+ .unwrap_or_else(|| config.settings.tld_list.clone());
|
|
|
|
|
+
|
|
|
|
|
+ Self {
|
|
|
|
|
+ search_input: String::new(),
|
|
|
|
|
+ cursor_pos: 0,
|
|
|
|
|
+ results: Vec::new(),
|
|
|
|
|
+ results_state: ListState::default(),
|
|
|
|
|
+ favorites: config.favorites.clone(),
|
|
|
|
|
+ favorites_state: ListState::default(),
|
|
|
|
|
+ focus: Focus::Search,
|
|
|
|
|
+ show_unavailable: config.settings.show_all || args.show_all,
|
|
|
|
|
+ clear_on_search: config.settings.clear_on_search,
|
|
|
|
|
+ tld_list_name,
|
|
|
|
|
+ settings_selected: Some(0),
|
|
|
|
|
+ show_notes_panel: config.settings.show_notes_panel,
|
|
|
|
|
+ last_fav_export_path: config.settings.last_fav_export_path.clone(),
|
|
|
|
|
+ last_res_export_path: config.settings.last_res_export_path.clone(),
|
|
|
|
|
+ scratchpad: config.scratchpad.clone(),
|
|
|
|
|
+ scratchpad_cursor: config.scratchpad.len(),
|
|
|
|
|
+ dropdown: DropdownState::Closed,
|
|
|
|
|
+ searching: false,
|
|
|
|
|
+ search_progress: (0, 0),
|
|
|
|
|
+ search_started_at: None,
|
|
|
|
|
+ last_search_duration: None,
|
|
|
|
|
+ status_msg: None,
|
|
|
|
|
+ config_path,
|
|
|
|
|
+ can_save,
|
|
|
|
|
+ cache_path,
|
|
|
|
|
+ force_refresh,
|
|
|
|
|
+ mouse_enabled: !args.no_mouse,
|
|
|
|
|
+ top_tlds: args.top_tlds.clone(),
|
|
|
|
|
+ only_top: args.only_top.clone(),
|
|
|
|
|
+ imported_lists: config.imported_filters.clone(),
|
|
|
|
|
+ cache_settings: config.cache.clone(),
|
|
|
|
|
+ verbose: args.verbose,
|
|
|
|
|
+ delay: args.effective_delay(),
|
|
|
|
|
+ retries: args.effective_retry(),
|
|
|
|
|
+ jobs: if args.jobs.is_some() { args.effective_jobs() } else { config.settings.jobs.max(1) },
|
|
|
|
|
+ panel_rects: PanelRects::default(),
|
|
|
|
|
+ stream_rx: None,
|
|
|
|
|
+ stream_task: None,
|
|
|
|
|
+ patch: whois_overrides,
|
|
|
|
|
+ noretry,
|
|
|
|
|
+ should_quit: false,
|
|
|
|
|
+ show_help: false,
|
|
|
|
|
+ export_popup: None,
|
|
|
|
|
+ fav_check_rx: None,
|
|
|
|
|
+ fav_check_task: None,
|
|
|
|
|
+ checking_favorites: false,
|
|
|
|
|
+ backups_enabled: config.settings.backups,
|
|
|
|
|
+ backup_count: config.settings.backup_count,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn get_effective_tlds(&self) -> Vec<&'static str> {
|
|
|
|
|
+ let mut tld_vec = self.base_tlds_for_selection();
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(ref only) = self.only_top {
|
|
|
|
|
+ tld_vec = only
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .filter(|s| !s.is_empty())
|
|
|
|
|
+ .map(|s| -> &'static str { Box::leak(s.clone().into_boxed_str()) })
|
|
|
|
|
+ .collect();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(ref top) = self.top_tlds {
|
|
|
|
|
+ tld_vec = apply_top_tlds(tld_vec, top);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ tld_vec
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn base_tlds_for_selection(&self) -> Vec<&'static str> {
|
|
|
|
|
+ if let Some(tlds) = crate::tlds::get_tlds(&self.tld_list_name) {
|
|
|
|
|
+ tlds
|
|
|
|
|
+ } else if let Some(imported) = self
|
|
|
|
|
+ .imported_lists
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .find(|list| list.name == self.tld_list_name)
|
|
|
|
|
+ {
|
|
|
|
|
+ imported
|
|
|
|
|
+ .tlds
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .map(|s| -> &'static str { Box::leak(s.clone().into_boxed_str()) })
|
|
|
|
|
+ .collect()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ get_tlds_or_default(default_list_name())
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn list_options(&self) -> Vec<String> {
|
|
|
|
|
+ let mut options: Vec<String> = list_names().iter().map(|s| s.to_string()).collect();
|
|
|
|
|
+ for imported in &self.imported_lists {
|
|
|
|
|
+ if !options.contains(&imported.name) {
|
|
|
|
|
+ options.push(imported.name.clone());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ options
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn parsed_queries(&self) -> Vec<String> {
|
|
|
|
|
+ self.search_input
|
|
|
|
|
+ .split(|c: char| c.is_whitespace() || c == ',')
|
|
|
|
|
+ .map(str::trim)
|
|
|
|
|
+ .filter(|s| !s.is_empty())
|
|
|
|
|
+ .map(ToOwned::to_owned)
|
|
|
|
|
+ .collect()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn default_export_dir() -> PathBuf {
|
|
|
|
|
+ #[cfg(debug_assertions)]
|
|
|
|
|
+ {
|
|
|
|
|
+ if let Ok(dir) = std::env::current_dir() {
|
|
|
|
|
+ return dir;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn suggested_export_path(&self, mode: ExportMode) -> String {
|
|
|
|
|
+ let last_export_path = match mode {
|
|
|
|
|
+ ExportMode::FavoritesTxt => &self.last_fav_export_path,
|
|
|
|
|
+ ExportMode::ResultsCsv => &self.last_res_export_path,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let base = if last_export_path.is_empty() {
|
|
|
|
|
+ Self::default_export_dir().join(mode.default_file_name())
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let last = PathBuf::from(last_export_path);
|
|
|
|
|
+ if last.is_dir() {
|
|
|
|
|
+ last.join(mode.default_file_name())
|
|
|
|
|
+ } else {
|
|
|
|
|
+ last
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ base.display().to_string()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn open_export_popup(&mut self) {
|
|
|
|
|
+ let mode = ExportMode::FavoritesTxt;
|
|
|
|
|
+ let path = self.suggested_export_path(mode);
|
|
|
|
|
+ self.export_popup = Some(ExportPopup {
|
|
|
|
|
+ mode,
|
|
|
|
|
+ selected_row: 0,
|
|
|
|
|
+ cursor_pos: path.len(),
|
|
|
|
|
+ path,
|
|
|
|
|
+ status: None,
|
|
|
|
|
+ status_success: false,
|
|
|
|
|
+ confirm_overwrite: false,
|
|
|
|
|
+ close_at: None,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn set_export_mode(&mut self, mode: ExportMode) {
|
|
|
|
|
+ let suggested_path = self.suggested_export_path(mode);
|
|
|
|
|
+ if let Some(popup) = &mut self.export_popup {
|
|
|
|
|
+ popup.mode = mode;
|
|
|
|
|
+ popup.path = suggested_path;
|
|
|
|
|
+ popup.cursor_pos = popup.path.len();
|
|
|
|
|
+ popup.status = None;
|
|
|
|
|
+ popup.status_success = false;
|
|
|
|
|
+ popup.confirm_overwrite = false;
|
|
|
|
|
+ popup.close_at = None;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn clear_results(&mut self) {
|
|
|
|
|
+ self.results.clear();
|
|
|
|
|
+ self.results_state.select(None);
|
|
|
|
|
+ self.search_progress = (0, 0);
|
|
|
|
|
+ self.status_msg = Some("Results cleared".to_string());
|
|
|
|
|
+ }
|
|
|
|
|
+ fn save_config(&self) {
|
|
|
|
|
+ if !self.can_save {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ let config = Config {
|
|
|
|
|
+ settings: crate::config::Settings {
|
|
|
|
|
+ tld_list: self.tld_list_name.clone(),
|
|
|
|
|
+ show_all: self.show_unavailable,
|
|
|
|
|
+ clear_on_search: self.clear_on_search,
|
|
|
|
|
+ show_notes_panel: self.show_notes_panel,
|
|
|
|
|
+ last_fav_export_path: self.last_fav_export_path.clone(),
|
|
|
|
|
+ last_res_export_path: self.last_res_export_path.clone(),
|
|
|
|
|
+ top_tlds: self.top_tlds.clone().unwrap_or_default(),
|
|
|
|
|
+ jobs: self.jobs,
|
|
|
|
|
+ noretry: self.noretry.iter().map(|k| k.to_config_str().to_string()).collect(),
|
|
|
|
|
+ backups: self.backups_enabled,
|
|
|
|
|
+ backup_count: self.backup_count,
|
|
|
|
|
+ },
|
|
|
|
|
+ favorites: self.favorites.clone(),
|
|
|
|
|
+ imported_filters: self.imported_lists.clone(),
|
|
|
|
|
+ cache: self.cache_settings.clone(),
|
|
|
|
|
+ scratchpad: self.scratchpad.clone(),
|
|
|
|
|
+ };
|
|
|
|
|
+ let _ = config.save(&self.config_path);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn add_favorite(&mut self, domain: &str) {
|
|
|
|
|
+ let d = domain.to_lowercase();
|
|
|
|
|
+ if !self.favorites.iter().any(|f| f.domain == d) {
|
|
|
|
|
+ // check if we just looked this domain up - inherit its status
|
|
|
|
|
+ let status = self.results.iter()
|
|
|
|
|
+ .find(|(_, r)| r.full.to_lowercase() == d)
|
|
|
|
|
+ .map(|(_, r)| r.status_str().to_string())
|
|
|
|
|
+ .unwrap_or_else(|| "unknown".to_string());
|
|
|
|
|
+ let checked = if status != "unknown" {
|
|
|
|
|
+ chrono::Utc::now().to_rfc3339()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ String::new()
|
|
|
|
|
+ };
|
|
|
|
|
+ self.favorites.push(FavoriteEntry {
|
|
|
|
|
+ domain: d,
|
|
|
|
|
+ status,
|
|
|
|
|
+ checked,
|
|
|
|
|
+ changed: false,
|
|
|
|
|
+ });
|
|
|
|
|
+ self.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn remove_focused_favorite(&mut self) {
|
|
|
|
|
+ if let Some(idx) = self.favorites_state.selected() {
|
|
|
|
|
+ if idx < self.favorites.len() {
|
|
|
|
|
+ self.favorites.remove(idx);
|
|
|
|
|
+ // adjust selection
|
|
|
|
|
+ if !self.favorites.is_empty() {
|
|
|
|
|
+ if idx >= self.favorites.len() {
|
|
|
|
|
+ self.favorites_state.select(Some(self.favorites.len() - 1));
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.favorites_state.select(None);
|
|
|
|
|
+ }
|
|
|
|
|
+ self.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn visible_results(&self) -> Vec<&DomainResult> {
|
|
|
|
|
+ if self.show_unavailable {
|
|
|
|
|
+ self.results.iter().map(|(_, r)| r).collect()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.results.iter().filter(|(_, r)| r.is_available()).map(|(_, r)| r).collect()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn set_tld_list_by_index(&mut self, idx: usize) {
|
|
|
|
|
+ let options = self.list_options();
|
|
|
|
|
+ if let Some(selected) = options.get(idx) {
|
|
|
|
|
+ self.tld_list_name = selected.clone();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.tld_list_name = default_list_name().to_string();
|
|
|
|
|
+ }
|
|
|
|
|
+ self.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn tld_list_index(&self) -> usize {
|
|
|
|
|
+ self.list_options()
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .position(|option| option == &self.tld_list_name)
|
|
|
|
|
+ .unwrap_or(0)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn set_focus(&mut self, focus: Focus) {
|
|
|
|
|
+ self.focus = focus;
|
|
|
|
|
+ if self.focus == Focus::Settings && self.settings_selected.is_none() {
|
|
|
|
|
+ self.settings_selected = Some(0);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn panel_at(&self, col: u16, row: u16) -> Option<Focus> {
|
|
|
|
|
+ let pos = (col, row);
|
|
|
|
|
+ if let Some(r) = self.panel_rects.search {
|
|
|
|
|
+ if contains_pos(r, pos) {
|
|
|
|
|
+ return Some(Focus::Search);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(r) = self.panel_rects.scratchpad {
|
|
|
|
|
+ if contains_pos(r, pos) {
|
|
|
|
|
+ return Some(Focus::Scratchpad);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(r) = self.panel_rects.results {
|
|
|
|
|
+ if contains_pos(r, pos) {
|
|
|
|
|
+ return Some(Focus::Results);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(r) = self.panel_rects.favorites {
|
|
|
|
|
+ if contains_pos(r, pos) {
|
|
|
|
|
+ return Some(Focus::Favorites);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(r) = self.panel_rects.settings {
|
|
|
|
|
+ if contains_pos(r, pos) {
|
|
|
|
|
+ return Some(Focus::Settings);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ None
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn contains_pos(rect: Rect, pos: (u16, u16)) -> bool {
|
|
|
|
|
+ pos.0 >= rect.x
|
|
|
|
|
+ && pos.0 < rect.x + rect.width
|
|
|
|
|
+ && pos.1 >= rect.y
|
|
|
|
|
+ && pos.1 < rect.y + rect.height
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn clamp_panel_size(value: u16, min: u16, max: u16) -> u16 {
|
|
|
|
|
+ value.max(min).min(max)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn export_popup_rect(area: Rect) -> Rect {
|
|
|
|
|
+ let width = EXPORT_POPUP_WIDTH.min(area.width.saturating_sub(4));
|
|
|
|
|
+ let height = EXPORT_POPUP_HEIGHT.min(area.height.saturating_sub(4));
|
|
|
|
|
+ Rect {
|
|
|
|
|
+ x: area.x + (area.width.saturating_sub(width)) / 2,
|
|
|
|
|
+ y: area.y + (area.height.saturating_sub(height)) / 2,
|
|
|
|
|
+ width,
|
|
|
|
|
+ height,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn help_popup_rect(area: Rect) -> Rect {
|
|
|
|
|
+ let width = if area.width > 54 {
|
|
|
|
|
+ area.width.saturating_sub(6).min(58)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ area.width.saturating_sub(2)
|
|
|
|
|
+ };
|
|
|
|
|
+ let height = if area.height > 22 {
|
|
|
|
|
+ 20
|
|
|
|
|
+ } else {
|
|
|
|
|
+ area.height.saturating_sub(2)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ Rect {
|
|
|
|
|
+ x: area.x + (area.width.saturating_sub(width)) / 2,
|
|
|
|
|
+ y: area.y + (area.height.saturating_sub(height)) / 2,
|
|
|
|
|
+ width,
|
|
|
|
|
+ height,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+pub async fn run_tui(
|
|
|
|
|
+ args: &Args,
|
|
|
|
|
+ config: &Config,
|
|
|
|
|
+ paths: crate::config::HoardomPaths,
|
|
|
|
|
+ cache_file: Option<PathBuf>,
|
|
|
|
|
+ force_refresh: bool,
|
|
|
|
|
+ whois_overrides: crate::tlds::WhoisOverrides,
|
|
|
|
|
+ noretry: Vec<ErrorKind>,
|
|
|
|
|
+) -> io::Result<()> {
|
|
|
|
|
+ // terminal setup
|
|
|
|
|
+ enable_raw_mode()?;
|
|
|
|
|
+ let mut stdout = io::stdout();
|
|
|
|
|
+ if !args.no_mouse {
|
|
|
|
|
+ execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ execute!(stdout, EnterAlternateScreen)?;
|
|
|
|
|
+ }
|
|
|
|
|
+ let backend = CrosstermBackend::new(stdout);
|
|
|
|
|
+ let mut terminal = Terminal::new(backend)?;
|
|
|
|
|
+
|
|
|
|
|
+ let mut app = App::new(args, config, paths.config_file.clone(), paths.can_save, cache_file, force_refresh, whois_overrides, noretry);
|
|
|
|
|
+
|
|
|
|
|
+ if !paths.can_save {
|
|
|
|
|
+ app.status_msg = Some("Warning: favorites and settings wont be saved".to_string());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let result = run_app(&mut terminal, &mut app).await;
|
|
|
|
|
+
|
|
|
|
|
+ // put the terminal back to normal
|
|
|
|
|
+ if !args.no_mouse {
|
|
|
|
|
+ execute!(
|
|
|
|
|
+ terminal.backend_mut(),
|
|
|
|
|
+ DisableMouseCapture
|
|
|
|
|
+ )?;
|
|
|
|
|
+
|
|
|
|
|
+ while event::poll(std::time::Duration::from_millis(0))? {
|
|
|
|
|
+ let _ = event::read();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
|
|
|
+ }
|
|
|
|
|
+ terminal.backend_mut().flush()?;
|
|
|
|
|
+ disable_raw_mode()?;
|
|
|
|
|
+ terminal.show_cursor()?;
|
|
|
|
|
+
|
|
|
|
|
+ result
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async fn run_app(
|
|
|
|
|
+ terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
|
|
|
+ app: &mut App,
|
|
|
|
|
+) -> io::Result<()> {
|
|
|
|
|
+ loop {
|
|
|
|
|
+ if app.should_quit {
|
|
|
|
|
+ return Ok(());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(popup) = app.export_popup.as_ref() {
|
|
|
|
|
+ if popup.close_at.is_some_and(|deadline| Instant::now() >= deadline) {
|
|
|
|
|
+ app.export_popup = None;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // poll streaming results if a search is in progress
|
|
|
|
|
+ if let Some(ref mut rx) = app.stream_rx {
|
|
|
|
|
+ // drain all available messages without blocking
|
|
|
|
|
+ loop {
|
|
|
|
|
+ match rx.try_recv() {
|
|
|
|
|
+ Ok(lookup::StreamMsg::Result { result, sort_index }) => {
|
|
|
|
|
+ // insert in sorted position to maintain list order
|
|
|
|
|
+ let pos = app.results.partition_point(|(idx, _)| *idx < sort_index);
|
|
|
|
|
+ app.results.insert(pos, (sort_index, result));
|
|
|
|
|
+ // auto-select first result
|
|
|
|
|
+ if app.results.len() == 1 {
|
|
|
|
|
+ app.results_state.select(Some(0));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(lookup::StreamMsg::Progress { current, total }) => {
|
|
|
|
|
+ app.search_progress = (current, total);
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(lookup::StreamMsg::Error(msg)) => {
|
|
|
|
|
+ app.status_msg = Some(format!("Error: {}", msg));
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(lookup::StreamMsg::Done) => {
|
|
|
|
|
+ app.searching = false;
|
|
|
|
|
+ app.last_search_duration = app.search_started_at.map(|t| t.elapsed());
|
|
|
|
|
+ app.search_started_at = None;
|
|
|
|
|
+ app.status_msg = None;
|
|
|
|
|
+ app.stream_rx = None;
|
|
|
|
|
+ app.stream_task = None;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
|
|
|
|
|
+ Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
|
|
|
|
|
+ app.searching = false;
|
|
|
|
|
+ app.last_search_duration = app.search_started_at.map(|t| t.elapsed());
|
|
|
|
|
+ app.search_started_at = None;
|
|
|
|
|
+ app.status_msg = None;
|
|
|
|
|
+ app.stream_rx = None;
|
|
|
|
|
+ app.stream_task = None;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // poll favorites check results
|
|
|
|
|
+ if let Some(ref mut rx) = app.fav_check_rx {
|
|
|
|
|
+ loop {
|
|
|
|
|
+ match rx.try_recv() {
|
|
|
|
|
+ Ok(lookup::StreamMsg::Result { result, .. }) => {
|
|
|
|
|
+ // Update the matching favorite's status
|
|
|
|
|
+ let domain_lower = result.full.to_lowercase();
|
|
|
|
|
+ if let Some(fav) = app.favorites.iter_mut().find(|f| f.domain == domain_lower) {
|
|
|
|
|
+ let new_status = result.status_str().to_string();
|
|
|
|
|
+ if fav.status != new_status && fav.status != "unknown" {
|
|
|
|
|
+ fav.changed = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ fav.status = new_status;
|
|
|
|
|
+ fav.checked = chrono::Utc::now().to_rfc3339();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(lookup::StreamMsg::Done) => {
|
|
|
|
|
+ app.checking_favorites = false;
|
|
|
|
|
+ app.fav_check_rx = None;
|
|
|
|
|
+ app.fav_check_task = None;
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(_) => {} // progress/error - dont care for fav checks
|
|
|
|
|
+ Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
|
|
|
|
|
+ Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
|
|
|
|
|
+ app.checking_favorites = false;
|
|
|
|
|
+ app.fav_check_rx = None;
|
|
|
|
|
+ app.fav_check_task = None;
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ terminal.draw(|f| draw_ui(f, app))?;
|
|
|
|
|
+
|
|
|
|
|
+ // poll for events with a timeout so we can update during searches
|
|
|
|
|
+ if event::poll(std::time::Duration::from_millis(50))? {
|
|
|
|
|
+ match event::read()? {
|
|
|
|
|
+ Event::Key(key) if key.kind == KeyEventKind::Press => {
|
|
|
|
|
+ if matches!(key.code, KeyCode::F(1)) {
|
|
|
|
|
+ app.show_help = !app.show_help;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if matches!(key.code, KeyCode::F(2)) {
|
|
|
|
|
+ if app.export_popup.is_some() {
|
|
|
|
|
+ app.export_popup = None;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ app.open_export_popup();
|
|
|
|
|
+ }
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if key.modifiers.contains(KeyModifiers::CONTROL)
|
|
|
|
|
+ && matches!(key.code, KeyCode::Char('c'))
|
|
|
|
|
+ {
|
|
|
|
|
+ quit_app(app);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if app.export_popup.is_some() {
|
|
|
|
|
+ handle_export_popup_key(app, key.code);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if app.searching && matches!(key.code, KeyCode::Char('s' | 'S')) {
|
|
|
|
|
+ cancel_search(app);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if !app.searching
|
|
|
|
|
+ && !app.clear_on_search
|
|
|
|
|
+ && matches!(key.code, KeyCode::Char('C'))
|
|
|
|
|
+ {
|
|
|
|
|
+ app.clear_results();
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // close dropdown on any key if its open (unless its the dropdown interaction)
|
|
|
|
|
+ if app.dropdown != DropdownState::Closed && app.focus != Focus::Settings {
|
|
|
|
|
+ app.dropdown = DropdownState::Closed;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ match key.code {
|
|
|
|
|
+ KeyCode::Esc => {
|
|
|
|
|
+ if app.show_help {
|
|
|
|
|
+ app.show_help = false;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ handle_escape_for_panel(app);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ // tab between panels
|
|
|
|
|
+ KeyCode::Tab => {
|
|
|
|
|
+ app.dropdown = DropdownState::Closed;
|
|
|
|
|
+ app.set_focus(match app.focus {
|
|
|
|
|
+ Focus::Search => {
|
|
|
|
|
+ if app.show_notes_panel { Focus::Scratchpad } else { Focus::Results }
|
|
|
|
|
+ }
|
|
|
|
|
+ Focus::Scratchpad => Focus::Results,
|
|
|
|
|
+ Focus::Results => Focus::Favorites,
|
|
|
|
|
+ Focus::Favorites => Focus::Settings,
|
|
|
|
|
+ Focus::Settings => Focus::Search,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::BackTab => {
|
|
|
|
|
+ app.dropdown = DropdownState::Closed;
|
|
|
|
|
+ app.set_focus(match app.focus {
|
|
|
|
|
+ Focus::Search => Focus::Settings,
|
|
|
|
|
+ Focus::Scratchpad => Focus::Search,
|
|
|
|
|
+ Focus::Results => {
|
|
|
|
|
+ if app.show_notes_panel { Focus::Scratchpad } else { Focus::Search }
|
|
|
|
|
+ }
|
|
|
|
|
+ Focus::Favorites => Focus::Results,
|
|
|
|
|
+ Focus::Settings => Focus::Favorites,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {
|
|
|
|
|
+ handle_key_for_panel(app, key.code).await;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Event::Mouse(mouse) => {
|
|
|
|
|
+ if app.mouse_enabled {
|
|
|
|
|
+ handle_mouse(app, mouse);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async fn handle_key_for_panel(app: &mut App, key: KeyCode) {
|
|
|
|
|
+ match app.focus {
|
|
|
|
|
+ Focus::Search => handle_search_key(app, key).await,
|
|
|
|
|
+ Focus::Scratchpad => handle_scratchpad_key(app, key),
|
|
|
|
|
+ Focus::Results => handle_results_key(app, key),
|
|
|
|
|
+ Focus::Favorites => handle_favorites_key(app, key),
|
|
|
|
|
+ Focus::Settings => handle_settings_key(app, key),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn run_export(app: &mut App) {
|
|
|
|
|
+ let Some(popup) = app.export_popup.as_ref() else {
|
|
|
|
|
+ return;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let mode = popup.mode;
|
|
|
|
|
+ let path_text = popup.path.clone();
|
|
|
|
|
+ let path = PathBuf::from(&path_text);
|
|
|
|
|
+
|
|
|
|
|
+ if path.is_file() && !popup.confirm_overwrite {
|
|
|
|
|
+ if let Some(popup) = app.export_popup.as_mut() {
|
|
|
|
|
+ popup.status = Some("File exists. Press Enter again to overwrite.".to_string());
|
|
|
|
|
+ popup.status_success = false;
|
|
|
|
|
+ popup.confirm_overwrite = true;
|
|
|
|
|
+ popup.close_at = None;
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let result = match mode {
|
|
|
|
|
+ ExportMode::FavoritesTxt => export_favorites_txt(&path, &app.favorites),
|
|
|
|
|
+ ExportMode::ResultsCsv => {
|
|
|
|
|
+ let visible = app.visible_results();
|
|
|
|
|
+ if visible.is_empty() {
|
|
|
|
|
+ Err("No results to export".to_string())
|
|
|
|
|
+ } else {
|
|
|
|
|
+ export_results_csv(&path, &visible)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ match result {
|
|
|
|
|
+ Ok(()) => {
|
|
|
|
|
+ match mode {
|
|
|
|
|
+ ExportMode::FavoritesTxt => app.last_fav_export_path = path_text.clone(),
|
|
|
|
|
+ ExportMode::ResultsCsv => app.last_res_export_path = path_text.clone(),
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(popup) = app.export_popup.as_mut() {
|
|
|
|
|
+ popup.status = Some("Success".to_string());
|
|
|
|
|
+ popup.status_success = true;
|
|
|
|
|
+ popup.confirm_overwrite = false;
|
|
|
|
|
+ popup.close_at = Some(Instant::now() + Duration::from_secs(2));
|
|
|
|
|
+ }
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ Err(err) => {
|
|
|
|
|
+ if let Some(popup) = app.export_popup.as_mut() {
|
|
|
|
|
+ popup.status = Some(err);
|
|
|
|
|
+ popup.status_success = false;
|
|
|
|
|
+ popup.confirm_overwrite = false;
|
|
|
|
|
+ popup.close_at = None;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn handle_export_popup_key(app: &mut App, key: KeyCode) {
|
|
|
|
|
+ let Some(popup) = app.export_popup.as_mut() else {
|
|
|
|
|
+ return;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ match key {
|
|
|
|
|
+ KeyCode::Esc => {
|
|
|
|
|
+ app.export_popup = None;
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Tab | KeyCode::Down => {
|
|
|
|
|
+ popup.selected_row = (popup.selected_row + 1) % 4;
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::BackTab | KeyCode::Up => {
|
|
|
|
|
+ popup.selected_row = if popup.selected_row == 0 { 3 } else { popup.selected_row - 1 };
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Left => {
|
|
|
|
|
+ if popup.selected_row == 0 {
|
|
|
|
|
+ let mode = popup.mode.toggled();
|
|
|
|
|
+ app.set_export_mode(mode);
|
|
|
|
|
+ } else if popup.selected_row == 3 {
|
|
|
|
|
+ popup.selected_row = 2;
|
|
|
|
|
+ } else if popup.selected_row == 1 && popup.cursor_pos > 0 {
|
|
|
|
|
+ popup.cursor_pos -= 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Right => {
|
|
|
|
|
+ if popup.selected_row == 0 {
|
|
|
|
|
+ let mode = popup.mode.toggled();
|
|
|
|
|
+ app.set_export_mode(mode);
|
|
|
|
|
+ } else if popup.selected_row == 2 {
|
|
|
|
|
+ popup.selected_row = 3;
|
|
|
|
|
+ } else if popup.selected_row == 1 && popup.cursor_pos < popup.path.len() {
|
|
|
|
|
+ popup.cursor_pos += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Home => {
|
|
|
|
|
+ if popup.selected_row == 1 {
|
|
|
|
|
+ popup.cursor_pos = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::End => {
|
|
|
|
|
+ if popup.selected_row == 1 {
|
|
|
|
|
+ popup.cursor_pos = popup.path.len();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Backspace => {
|
|
|
|
|
+ if popup.selected_row == 1 && popup.cursor_pos > 0 {
|
|
|
|
|
+ popup.cursor_pos -= 1;
|
|
|
|
|
+ popup.path.remove(popup.cursor_pos);
|
|
|
|
|
+ popup.status = None;
|
|
|
|
|
+ popup.status_success = false;
|
|
|
|
|
+ popup.confirm_overwrite = false;
|
|
|
|
|
+ popup.close_at = None;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Delete => {
|
|
|
|
|
+ if popup.selected_row == 1 && popup.cursor_pos < popup.path.len() {
|
|
|
|
|
+ popup.path.remove(popup.cursor_pos);
|
|
|
|
|
+ popup.status = None;
|
|
|
|
|
+ popup.status_success = false;
|
|
|
|
|
+ popup.confirm_overwrite = false;
|
|
|
|
|
+ popup.close_at = None;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Char(c) => {
|
|
|
|
|
+ if popup.selected_row == 1 {
|
|
|
|
|
+ popup.path.insert(popup.cursor_pos, c);
|
|
|
|
|
+ popup.cursor_pos += 1;
|
|
|
|
|
+ popup.status = None;
|
|
|
|
|
+ popup.status_success = false;
|
|
|
|
|
+ popup.confirm_overwrite = false;
|
|
|
|
|
+ popup.close_at = None;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Enter => match popup.selected_row {
|
|
|
|
|
+ 0 => {
|
|
|
|
|
+ let mode = popup.mode.toggled();
|
|
|
|
|
+ app.set_export_mode(mode);
|
|
|
|
|
+ }
|
|
|
|
|
+ 1 => run_export(app),
|
|
|
|
|
+ 2 => app.export_popup = None,
|
|
|
|
|
+ 3 => run_export(app),
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ },
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn handle_scratchpad_key(app: &mut App, key: KeyCode) {
|
|
|
|
|
+ match key {
|
|
|
|
|
+ KeyCode::Char(c) => {
|
|
|
|
|
+ app.scratchpad.insert(app.scratchpad_cursor, c);
|
|
|
|
|
+ app.scratchpad_cursor += 1;
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Enter => {
|
|
|
|
|
+ app.scratchpad.insert(app.scratchpad_cursor, '\n');
|
|
|
|
|
+ app.scratchpad_cursor += 1;
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Backspace => {
|
|
|
|
|
+ if app.scratchpad_cursor > 0 {
|
|
|
|
|
+ app.scratchpad_cursor -= 1;
|
|
|
|
|
+ app.scratchpad.remove(app.scratchpad_cursor);
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Delete => {
|
|
|
|
|
+ if app.scratchpad_cursor < app.scratchpad.len() {
|
|
|
|
|
+ app.scratchpad.remove(app.scratchpad_cursor);
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Left => {
|
|
|
|
|
+ if app.scratchpad_cursor > 0 {
|
|
|
|
|
+ app.scratchpad_cursor -= 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Right => {
|
|
|
|
|
+ if app.scratchpad_cursor < app.scratchpad.len() {
|
|
|
|
|
+ app.scratchpad_cursor += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Up => {
|
|
|
|
|
+ move_scratchpad_cursor_vertical(app, -1);
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Down => {
|
|
|
|
|
+ move_scratchpad_cursor_vertical(app, 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Home => {
|
|
|
|
|
+ app.scratchpad_cursor = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::End => {
|
|
|
|
|
+ app.scratchpad_cursor = app.scratchpad.len();
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async fn handle_search_key(app: &mut App, key: KeyCode) {
|
|
|
|
|
+ if app.searching {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ match key {
|
|
|
|
|
+ KeyCode::Enter => {
|
|
|
|
|
+ if !app.search_input.is_empty() && !app.searching {
|
|
|
|
|
+ start_search(app);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Char(c) => {
|
|
|
|
|
+ // only allow valid domain chars (alphanumeric, hyphen, dot, space for multi query)
|
|
|
|
|
+ if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == ' ' {
|
|
|
|
|
+ app.search_input.insert(app.cursor_pos, c);
|
|
|
|
|
+ app.cursor_pos += c.len_utf8();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Backspace => {
|
|
|
|
|
+ if app.cursor_pos > 0 {
|
|
|
|
|
+ // Find the previous char boundary
|
|
|
|
|
+ let prev = app.search_input[..app.cursor_pos]
|
|
|
|
|
+ .char_indices()
|
|
|
|
|
+ .next_back()
|
|
|
|
|
+ .map(|(i, _)| i)
|
|
|
|
|
+ .unwrap_or(0);
|
|
|
|
|
+ app.search_input.remove(prev);
|
|
|
|
|
+ app.cursor_pos = prev;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Delete => {
|
|
|
|
|
+ if app.cursor_pos < app.search_input.len() && app.search_input.is_char_boundary(app.cursor_pos) {
|
|
|
|
|
+ app.search_input.remove(app.cursor_pos);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Left => {
|
|
|
|
|
+ if app.cursor_pos > 0 {
|
|
|
|
|
+ app.cursor_pos = app.search_input[..app.cursor_pos]
|
|
|
|
|
+ .char_indices()
|
|
|
|
|
+ .next_back()
|
|
|
|
|
+ .map(|(i, _)| i)
|
|
|
|
|
+ .unwrap_or(0);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Right => {
|
|
|
|
|
+ if app.cursor_pos < app.search_input.len() {
|
|
|
|
|
+ app.cursor_pos = app.search_input[app.cursor_pos..]
|
|
|
|
|
+ .char_indices()
|
|
|
|
|
+ .nth(1)
|
|
|
|
|
+ .map(|(i, _)| app.cursor_pos + i)
|
|
|
|
|
+ .unwrap_or(app.search_input.len());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Home => {
|
|
|
|
|
+ app.cursor_pos = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::End => {
|
|
|
|
|
+ app.cursor_pos = app.search_input.len();
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn handle_results_key(app: &mut App, key: KeyCode) {
|
|
|
|
|
+ let visible = app.visible_results();
|
|
|
|
|
+ let len = visible.len();
|
|
|
|
|
+ if len == 0 {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ match key {
|
|
|
|
|
+ KeyCode::Up => {
|
|
|
|
|
+ let i = match app.results_state.selected() {
|
|
|
|
|
+ Some(i) => {
|
|
|
|
|
+ if i > 0 { i - 1 } else { 0 }
|
|
|
|
|
+ }
|
|
|
|
|
+ None => 0,
|
|
|
|
|
+ };
|
|
|
|
|
+ app.results_state.select(Some(i));
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Down => {
|
|
|
|
|
+ let i = match app.results_state.selected() {
|
|
|
|
|
+ Some(i) => {
|
|
|
|
|
+ if i + 1 < len { i + 1 } else { i }
|
|
|
|
|
+ }
|
|
|
|
|
+ None => 0,
|
|
|
|
|
+ };
|
|
|
|
|
+ app.results_state.select(Some(i));
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Enter => {
|
|
|
|
|
+ // add focused domain to favorites
|
|
|
|
|
+ if let Some(idx) = app.results_state.selected() {
|
|
|
|
|
+ let visible = app.visible_results();
|
|
|
|
|
+ if let Some(result) = visible.get(idx) {
|
|
|
|
|
+ let domain = result.full.clone();
|
|
|
|
|
+ app.add_favorite(&domain);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn handle_favorites_key(app: &mut App, key: KeyCode) {
|
|
|
|
|
+ let len = app.favorites.len();
|
|
|
|
|
+ if len == 0 {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ match key {
|
|
|
|
|
+ KeyCode::Up => {
|
|
|
|
|
+ let i = match app.favorites_state.selected() {
|
|
|
|
|
+ Some(i) => {
|
|
|
|
|
+ if i > 0 { i - 1 } else { 0 }
|
|
|
|
|
+ }
|
|
|
|
|
+ None => 0,
|
|
|
|
|
+ };
|
|
|
|
|
+ app.favorites_state.select(Some(i));
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Down => {
|
|
|
|
|
+ let i = match app.favorites_state.selected() {
|
|
|
|
|
+ Some(i) => {
|
|
|
|
|
+ if i + 1 < len { i + 1 } else { i }
|
|
|
|
|
+ }
|
|
|
|
|
+ None => 0,
|
|
|
|
|
+ };
|
|
|
|
|
+ app.favorites_state.select(Some(i));
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Enter => {
|
|
|
|
|
+ // acknowledge status change - clear the ! marker
|
|
|
|
|
+ if let Some(idx) = app.favorites_state.selected() {
|
|
|
|
|
+ if let Some(fav) = app.favorites.get_mut(idx) {
|
|
|
|
|
+ if fav.changed {
|
|
|
|
|
+ fav.changed = false;
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Backspace | KeyCode::Delete => {
|
|
|
|
|
+ app.remove_focused_favorite();
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Char('c') | KeyCode::Char('C') => {
|
|
|
|
|
+ start_fav_check(app);
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn handle_settings_key(app: &mut App, key: KeyCode) {
|
|
|
|
|
+ match &app.dropdown {
|
|
|
|
|
+ DropdownState::Open(current) => {
|
|
|
|
|
+ let current = *current;
|
|
|
|
|
+ let option_count = app.list_options().len();
|
|
|
|
|
+ match key {
|
|
|
|
|
+ KeyCode::Up => {
|
|
|
|
|
+ let new = if option_count == 0 {
|
|
|
|
|
+ 0
|
|
|
|
|
+ } else if current > 0 {
|
|
|
|
|
+ current - 1
|
|
|
|
|
+ } else {
|
|
|
|
|
+ option_count - 1
|
|
|
|
|
+ };
|
|
|
|
|
+ app.dropdown = DropdownState::Open(new);
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Down => {
|
|
|
|
|
+ let new = if option_count == 0 {
|
|
|
|
|
+ 0
|
|
|
|
|
+ } else if current + 1 < option_count {
|
|
|
|
|
+ current + 1
|
|
|
|
|
+ } else {
|
|
|
|
|
+ 0
|
|
|
|
|
+ };
|
|
|
|
|
+ app.dropdown = DropdownState::Open(new);
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Enter => {
|
|
|
|
|
+ app.set_tld_list_by_index(current);
|
|
|
|
|
+ app.dropdown = DropdownState::Closed;
|
|
|
|
|
+ app.settings_selected = Some(0);
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Esc => {
|
|
|
|
|
+ app.dropdown = DropdownState::Closed;
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ DropdownState::Closed => {
|
|
|
|
|
+ match key {
|
|
|
|
|
+ KeyCode::Up => {
|
|
|
|
|
+ app.settings_selected = Some(match app.settings_selected.unwrap_or(0) {
|
|
|
|
|
+ 0 => 4,
|
|
|
|
|
+ n => n - 1,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Down => {
|
|
|
|
|
+ app.settings_selected = Some(match app.settings_selected.unwrap_or(0) {
|
|
|
|
|
+ 0 => 1,
|
|
|
|
|
+ 1 => 2,
|
|
|
|
|
+ 2 => 3,
|
|
|
|
|
+ 3 => 4,
|
|
|
|
|
+ _ => 0,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Enter => {
|
|
|
|
|
+ match app.settings_selected.unwrap_or(0) {
|
|
|
|
|
+ 0 => {
|
|
|
|
|
+ app.dropdown = DropdownState::Open(app.tld_list_index());
|
|
|
|
|
+ }
|
|
|
|
|
+ 1 => {
|
|
|
|
|
+ app.show_unavailable = !app.show_unavailable;
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ 2 => {
|
|
|
|
|
+ app.show_notes_panel = !app.show_notes_panel;
|
|
|
|
|
+ if !app.show_notes_panel && app.focus == Focus::Scratchpad {
|
|
|
|
|
+ app.set_focus(Focus::Results);
|
|
|
|
|
+ }
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ 3 => {
|
|
|
|
|
+ app.clear_on_search = !app.clear_on_search;
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ 4 => {
|
|
|
|
|
+ // increment jobs (wrap at 99 -> 1)
|
|
|
|
|
+ app.jobs = if app.jobs >= 99 { 1 } else { app.jobs + 1 };
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Char(' ') => {
|
|
|
|
|
+ match app.settings_selected.unwrap_or(0) {
|
|
|
|
|
+ 1 => {
|
|
|
|
|
+ app.show_unavailable = !app.show_unavailable;
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ 2 => {
|
|
|
|
|
+ app.show_notes_panel = !app.show_notes_panel;
|
|
|
|
|
+ if !app.show_notes_panel && app.focus == Focus::Scratchpad {
|
|
|
|
|
+ app.set_focus(Focus::Results);
|
|
|
|
|
+ }
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ 3 => {
|
|
|
|
|
+ app.clear_on_search = !app.clear_on_search;
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Char('+') | KeyCode::Char('=') => {
|
|
|
|
|
+ if app.settings_selected == Some(4) {
|
|
|
|
|
+ app.jobs = if app.jobs >= 99 { 99 } else { app.jobs + 1 };
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Char('-') => {
|
|
|
|
|
+ if app.settings_selected == Some(4) {
|
|
|
|
|
+ app.jobs = if app.jobs <= 1 { 1 } else { app.jobs - 1 };
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Left => {
|
|
|
|
|
+ if app.settings_selected == Some(4) {
|
|
|
|
|
+ app.jobs = if app.jobs <= 1 { 1 } else { app.jobs - 1 };
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ KeyCode::Right => {
|
|
|
|
|
+ if app.settings_selected == Some(4) {
|
|
|
|
|
+ app.jobs = if app.jobs >= 99 { 99 } else { app.jobs + 1 };
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn handle_mouse(app: &mut App, mouse: MouseEvent) {
|
|
|
|
|
+ match mouse.kind {
|
|
|
|
|
+ MouseEventKind::Down(MouseButton::Left) => {
|
|
|
|
|
+ let col = mouse.column;
|
|
|
|
|
+ let row = mouse.row;
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(help_popup) = app.panel_rects.help_popup {
|
|
|
|
|
+ if !contains_pos(help_popup, (col, row)) {
|
|
|
|
|
+ app.show_help = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(export_popup) = app.panel_rects.export_popup {
|
|
|
|
|
+ if !contains_pos(export_popup, (col, row)) {
|
|
|
|
|
+ app.export_popup = None;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(mode_rect) = app.panel_rects.export_mode_favorites {
|
|
|
|
|
+ if contains_pos(mode_rect, (col, row)) {
|
|
|
|
|
+ app.set_export_mode(ExportMode::FavoritesTxt);
|
|
|
|
|
+ if let Some(popup) = app.export_popup.as_mut() {
|
|
|
|
|
+ popup.selected_row = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(mode_rect) = app.panel_rects.export_mode_results {
|
|
|
|
|
+ if contains_pos(mode_rect, (col, row)) {
|
|
|
|
|
+ app.set_export_mode(ExportMode::ResultsCsv);
|
|
|
|
|
+ if let Some(popup) = app.export_popup.as_mut() {
|
|
|
|
|
+ popup.selected_row = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(path_rect) = app.panel_rects.export_path {
|
|
|
|
|
+ if contains_pos(path_rect, (col, row)) {
|
|
|
|
|
+ if let Some(popup) = app.export_popup.as_mut() {
|
|
|
|
|
+ popup.selected_row = 1;
|
|
|
|
|
+ let clicked = col.saturating_sub(path_rect.x + 1) as usize;
|
|
|
|
|
+ popup.cursor_pos = clicked.min(popup.path.len());
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(cancel_rect) = app.panel_rects.export_cancel {
|
|
|
|
|
+ if contains_pos(cancel_rect, (col, row)) {
|
|
|
|
|
+ app.export_popup = None;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(save_rect) = app.panel_rects.export_save {
|
|
|
|
|
+ if contains_pos(save_rect, (col, row)) {
|
|
|
|
|
+ if let Some(popup) = app.export_popup.as_mut() {
|
|
|
|
|
+ popup.selected_row = 3;
|
|
|
|
|
+ }
|
|
|
|
|
+ run_export(app);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(dropdown_rect) = app.panel_rects.dropdown {
|
|
|
|
|
+ if contains_pos(dropdown_rect, (col, row)) {
|
|
|
|
|
+ let item_row = row.saturating_sub(dropdown_rect.y + 1) as usize;
|
|
|
|
|
+ let options = app.list_options();
|
|
|
|
|
+ if item_row < options.len() {
|
|
|
|
|
+ app.set_tld_list_by_index(item_row);
|
|
|
|
|
+ }
|
|
|
|
|
+ app.dropdown = DropdownState::Closed;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ app.dropdown = DropdownState::Closed;
|
|
|
|
|
+ app.panel_rects.dropdown = None;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(topbar) = app.panel_rects.topbar {
|
|
|
|
|
+ if contains_pos(topbar, (col, row)) {
|
|
|
|
|
+ let close_end = topbar.x + 2;
|
|
|
|
|
+ if col <= close_end {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(export_button) = app.panel_rects.export_button {
|
|
|
|
|
+ if contains_pos(export_button, (col, row)) {
|
|
|
|
|
+ app.open_export_popup();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(help_button) = app.panel_rects.help_button {
|
|
|
|
|
+ if contains_pos(help_button, (col, row)) {
|
|
|
|
|
+ app.show_help = !app.show_help;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(search_rect) = app.panel_rects.search {
|
|
|
|
|
+ if contains_pos(search_rect, (col, row)) {
|
|
|
|
|
+ app.set_focus(Focus::Search);
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(search_button) = app.panel_rects.search_button {
|
|
|
|
|
+ if contains_pos(search_button, (col, row)) {
|
|
|
|
|
+ if !app.searching && !app.search_input.is_empty() {
|
|
|
|
|
+ start_search(app);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(cancel_button) = app.panel_rects.cancel_button {
|
|
|
|
|
+ if contains_pos(cancel_button, (col, row)) {
|
|
|
|
|
+ if app.searching {
|
|
|
|
|
+ cancel_search(app);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(clear_button) = app.panel_rects.clear_button {
|
|
|
|
|
+ if contains_pos(clear_button, (col, row)) {
|
|
|
|
|
+ app.clear_results();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(scratchpad_rect) = app.panel_rects.scratchpad {
|
|
|
|
|
+ if contains_pos(scratchpad_rect, (col, row)) {
|
|
|
|
|
+ app.set_focus(Focus::Scratchpad);
|
|
|
|
|
+ app.scratchpad_cursor = app.scratchpad.len();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // check if clicking inside settings panel for interactive elements
|
|
|
|
|
+ if let Some(settings_rect) = app.panel_rects.settings {
|
|
|
|
|
+ if contains_pos(settings_rect, (col, row)) {
|
|
|
|
|
+ app.set_focus(Focus::Settings);
|
|
|
|
|
+
|
|
|
|
|
+ // row offsets within settings panel (1 = border)
|
|
|
|
|
+ let local_row = row.saturating_sub(settings_rect.y + 1);
|
|
|
|
|
+
|
|
|
|
|
+ if app.dropdown == DropdownState::Closed {
|
|
|
|
|
+ // row 0 = TLD list line, row 1 = checkbox line
|
|
|
|
|
+ if local_row == 0 {
|
|
|
|
|
+ app.settings_selected = Some(0);
|
|
|
|
|
+ // open TLD dropdown
|
|
|
|
|
+ app.dropdown = DropdownState::Open(app.tld_list_index());
|
|
|
|
|
+ } else if local_row == 1 {
|
|
|
|
|
+ app.settings_selected = Some(1);
|
|
|
|
|
+ // toggle show unavailable checkbox
|
|
|
|
|
+ app.show_unavailable = !app.show_unavailable;
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ } else if local_row == 2 {
|
|
|
|
|
+ app.settings_selected = Some(2);
|
|
|
|
|
+ app.show_notes_panel = !app.show_notes_panel;
|
|
|
|
|
+ if !app.show_notes_panel && app.focus == Focus::Scratchpad {
|
|
|
|
|
+ app.set_focus(Focus::Results);
|
|
|
|
|
+ }
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ } else if local_row == 3 {
|
|
|
|
|
+ app.settings_selected = Some(3);
|
|
|
|
|
+ app.clear_on_search = !app.clear_on_search;
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ } else if local_row == 4 {
|
|
|
|
|
+ app.settings_selected = Some(4);
|
|
|
|
|
+ // clicking on jobs row increments (use keyboard -/+ for fine control)
|
|
|
|
|
+ app.jobs = if app.jobs >= 99 { 1 } else { app.jobs + 1 };
|
|
|
|
|
+ app.save_config();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // check if clicking inside results panel if so select that row
|
|
|
|
|
+ if let Some(results_rect) = app.panel_rects.results {
|
|
|
|
|
+ if contains_pos(results_rect, (col, row)) {
|
|
|
|
|
+ app.set_focus(Focus::Results);
|
|
|
|
|
+ let visible_len = app.visible_results().len();
|
|
|
|
|
+ let content_start = results_rect.y + 1;
|
|
|
|
|
+ let progress_offset = if app.searching && app.search_progress.1 > 0 { 1 } else { 0 };
|
|
|
|
|
+ let header_offset = if visible_len > 0 { 1 } else { 0 };
|
|
|
|
|
+ let list_start = content_start + progress_offset + header_offset;
|
|
|
|
|
+
|
|
|
|
|
+ if row < list_start {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let clicked_idx = row.saturating_sub(list_start) as usize;
|
|
|
|
|
+ if clicked_idx < visible_len {
|
|
|
|
|
+ app.results_state.select(Some(clicked_idx));
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // check if clicking the fav check button
|
|
|
|
|
+ if let Some(btn_rect) = app.panel_rects.fav_check_button {
|
|
|
|
|
+ if contains_pos(btn_rect, (col, row)) {
|
|
|
|
|
+ start_fav_check(app);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // check if clicking inside favorites panel - select that row
|
|
|
|
|
+ if let Some(fav_rect) = app.panel_rects.favorites {
|
|
|
|
|
+ if contains_pos(fav_rect, (col, row)) {
|
|
|
|
|
+ app.set_focus(Focus::Favorites);
|
|
|
|
|
+ let content_start = fav_rect.y + 1;
|
|
|
|
|
+ let clicked_idx = (row.saturating_sub(content_start)) as usize;
|
|
|
|
|
+ if clicked_idx < app.favorites.len() {
|
|
|
|
|
+ app.favorites_state.select(Some(clicked_idx));
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // default: just switch focus to clicked panel
|
|
|
|
|
+ if let Some(panel) = app.panel_at(col, row) {
|
|
|
|
|
+ app.set_focus(panel);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ MouseEventKind::Up(MouseButton::Left) => {
|
|
|
|
|
+ let col = mouse.column;
|
|
|
|
|
+ let row = mouse.row;
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(topbar) = app.panel_rects.topbar {
|
|
|
|
|
+ if contains_pos(topbar, (col, row)) {
|
|
|
|
|
+ let close_end = topbar.x + 2;
|
|
|
|
|
+ if col <= close_end {
|
|
|
|
|
+ quit_app(app);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ MouseEventKind::ScrollUp => {
|
|
|
|
|
+ // scroll up in focused list
|
|
|
|
|
+ match app.focus {
|
|
|
|
|
+ Focus::Results => {
|
|
|
|
|
+ if let Some(i) = app.results_state.selected() {
|
|
|
|
|
+ if i > 0 {
|
|
|
|
|
+ app.results_state.select(Some(i - 1));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Focus::Favorites => {
|
|
|
|
|
+ if let Some(i) = app.favorites_state.selected() {
|
|
|
|
|
+ if i > 0 {
|
|
|
|
|
+ app.favorites_state.select(Some(i - 1));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ MouseEventKind::ScrollDown => {
|
|
|
|
|
+ // scroll down in focused list
|
|
|
|
|
+ match app.focus {
|
|
|
|
|
+ Focus::Results => {
|
|
|
|
|
+ let len = app.visible_results().len();
|
|
|
|
|
+ if let Some(i) = app.results_state.selected() {
|
|
|
|
|
+ if i + 1 < len {
|
|
|
|
|
+ app.results_state.select(Some(i + 1));
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if len > 0 {
|
|
|
|
|
+ app.results_state.select(Some(0));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Focus::Favorites => {
|
|
|
|
|
+ let len = app.favorites.len();
|
|
|
|
|
+ if let Some(i) = app.favorites_state.selected() {
|
|
|
|
|
+ if i + 1 < len {
|
|
|
|
|
+ app.favorites_state.select(Some(i + 1));
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if len > 0 {
|
|
|
|
|
+ app.favorites_state.select(Some(0));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn cancel_search(app: &mut App) {
|
|
|
|
|
+ if let Some(handle) = app.stream_task.take() {
|
|
|
|
|
+ handle.abort();
|
|
|
|
|
+ }
|
|
|
|
|
+ app.stream_rx = None;
|
|
|
|
|
+ app.searching = false;
|
|
|
|
|
+ app.search_progress = (0, 0);
|
|
|
|
|
+ app.search_started_at = None;
|
|
|
|
|
+ app.last_search_duration = None;
|
|
|
|
|
+ app.status_msg = Some("Search canceled".to_string());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn quit_app(app: &mut App) {
|
|
|
|
|
+ if let Some(handle) = app.stream_task.take() {
|
|
|
|
|
+ handle.abort();
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Some(handle) = app.fav_check_task.take() {
|
|
|
|
|
+ handle.abort();
|
|
|
|
|
+ }
|
|
|
|
|
+ app.stream_rx = None;
|
|
|
|
|
+ app.fav_check_rx = None;
|
|
|
|
|
+
|
|
|
|
|
+ // backup on shutdown if enabled
|
|
|
|
|
+ if app.can_save && app.backups_enabled {
|
|
|
|
|
+ let _ = Config::create_backup(&app.config_path, app.backup_count);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ app.should_quit = true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// kick off checking all favorites availability in the bg
|
|
|
|
|
+fn start_fav_check(app: &mut App) {
|
|
|
|
|
+ if app.checking_favorites || app.favorites.is_empty() {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Cancel any previous fav check
|
|
|
|
|
+ if let Some(handle) = app.fav_check_task.take() {
|
|
|
|
|
+ handle.abort();
|
|
|
|
|
+ }
|
|
|
|
|
+ app.fav_check_rx = None;
|
|
|
|
|
+
|
|
|
|
|
+ app.checking_favorites = true;
|
|
|
|
|
+
|
|
|
|
|
+ // Build a batch: each favorite is "name.tld" -> lookup (name, [tld])
|
|
|
|
|
+ let batches: lookup::LookupBatch = app.favorites.iter().filter_map(|fav| {
|
|
|
|
|
+ let parts: Vec<&str> = fav.domain.splitn(2, '.').collect();
|
|
|
|
|
+ if parts.len() == 2 {
|
|
|
|
|
+ Some((parts[0].to_string(), vec![parts[1].to_string()]))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ None
|
|
|
|
|
+ }
|
|
|
|
|
+ }).collect();
|
|
|
|
|
+
|
|
|
|
|
+ if batches.is_empty() {
|
|
|
|
|
+ app.checking_favorites = false;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let stream = lookup::lookup_many_streaming(
|
|
|
|
|
+ batches,
|
|
|
|
|
+ app.delay,
|
|
|
|
|
+ app.retries,
|
|
|
|
|
+ app.verbose,
|
|
|
|
|
+ app.cache_path.clone(),
|
|
|
|
|
+ false, // dont force refresh
|
|
|
|
|
+ app.jobs,
|
|
|
|
|
+ app.patch.clone(),
|
|
|
|
|
+ app.noretry.clone(),
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ app.fav_check_task = Some(stream.handle);
|
|
|
|
|
+ app.fav_check_rx = Some(stream.receiver);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn handle_escape_for_panel(app: &mut App) {
|
|
|
|
|
+ if app.dropdown != DropdownState::Closed {
|
|
|
|
|
+ app.dropdown = DropdownState::Closed;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ match app.focus {
|
|
|
|
|
+ Focus::Scratchpad => {}
|
|
|
|
|
+ Focus::Results => {
|
|
|
|
|
+ app.results_state.select(None);
|
|
|
|
|
+ }
|
|
|
|
|
+ Focus::Favorites => {
|
|
|
|
|
+ app.favorites_state.select(None);
|
|
|
|
|
+ }
|
|
|
|
|
+ Focus::Settings => {
|
|
|
|
|
+ app.settings_selected = None;
|
|
|
|
|
+ }
|
|
|
|
|
+ Focus::Search => {}
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn start_search(app: &mut App) {
|
|
|
|
|
+ if app.searching {
|
|
|
|
|
+ cancel_search(app);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let search_terms = app.parsed_queries();
|
|
|
|
|
+ if search_terms.is_empty() {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ app.searching = true;
|
|
|
|
|
+ if app.clear_on_search {
|
|
|
|
|
+ app.results.clear();
|
|
|
|
|
+ app.results_state.select(None);
|
|
|
|
|
+ }
|
|
|
|
|
+ app.search_progress = (0, 0);
|
|
|
|
|
+ app.search_started_at = Some(Instant::now());
|
|
|
|
|
+ app.last_search_duration = None;
|
|
|
|
|
+ app.status_msg = Some("Searching...".to_string());
|
|
|
|
|
+
|
|
|
|
|
+ let default_tlds = app.get_effective_tlds();
|
|
|
|
|
+ if default_tlds.is_empty() {
|
|
|
|
|
+ app.status_msg = Some("No TLDs to search".to_string());
|
|
|
|
|
+ app.searching = false;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let search_batches: lookup::LookupBatch = search_terms
|
|
|
|
|
+ .into_iter()
|
|
|
|
|
+ .map(|term| {
|
|
|
|
|
+ if term.contains('.') {
|
|
|
|
|
+ let mut parts = term.splitn(2, '.');
|
|
|
|
|
+ let name = parts.next().unwrap_or_default().to_string();
|
|
|
|
|
+ let tld = parts.next().unwrap_or_default().to_string();
|
|
|
|
|
+ (name, vec![tld])
|
|
|
|
|
+ } else {
|
|
|
|
|
+ (
|
|
|
|
|
+ term,
|
|
|
|
|
+ default_tlds.iter().map(|tld| (*tld).to_string()).collect(),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .filter(|(name, tlds)| !name.is_empty() && !tlds.is_empty())
|
|
|
|
|
+ .collect();
|
|
|
|
|
+
|
|
|
|
|
+ if search_batches.is_empty() {
|
|
|
|
|
+ app.status_msg = Some("No valid search terms".to_string());
|
|
|
|
|
+ app.searching = false;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let stream = lookup::lookup_many_streaming(
|
|
|
|
|
+ search_batches,
|
|
|
|
|
+ app.delay,
|
|
|
|
|
+ app.retries,
|
|
|
|
|
+ app.verbose,
|
|
|
|
|
+ app.cache_path.clone(),
|
|
|
|
|
+ app.force_refresh,
|
|
|
|
|
+ app.jobs,
|
|
|
|
|
+ app.patch.clone(),
|
|
|
|
|
+ app.noretry.clone(),
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // only force refresh on first search
|
|
|
|
|
+ app.force_refresh = false;
|
|
|
|
|
+ app.stream_task = Some(stream.handle);
|
|
|
|
|
+ app.stream_rx = Some(stream.receiver);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn draw_ui(f: &mut Frame, app: &mut App) {
|
|
|
|
|
+ let size = f.area();
|
|
|
|
|
+
|
|
|
|
|
+ if terminal_too_small(size) {
|
|
|
|
|
+ app.panel_rects = PanelRects::default();
|
|
|
|
|
+ draw_terminal_too_small(f, size);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // main layout: top bar + content area + search bar at bottom
|
|
|
|
|
+ let main_chunks = Layout::default()
|
|
|
|
|
+ .direction(Direction::Vertical)
|
|
|
|
|
+ .constraints([
|
|
|
|
|
+ Constraint::Length(TOPBAR_HEIGHT),
|
|
|
|
|
+ Constraint::Min(CONTENT_MIN_HEIGHT),
|
|
|
|
|
+ Constraint::Length(SEARCH_PANEL_HEIGHT),
|
|
|
|
|
+ ])
|
|
|
|
|
+ .split(size);
|
|
|
|
|
+
|
|
|
|
|
+ let content_area = main_chunks[1];
|
|
|
|
|
+ let desired_sidebar = content_area.width.saturating_mul(SIDEBAR_TARGET_WIDTH_PERCENT) / 100;
|
|
|
|
|
+ let mut sidebar_width = clamp_panel_size(desired_sidebar, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH)
|
|
|
|
|
+ .min(content_area.width.saturating_sub(RESULTS_MIN_WIDTH));
|
|
|
|
|
+ if sidebar_width == 0 {
|
|
|
|
|
+ sidebar_width = SIDEBAR_MIN_WIDTH.min(content_area.width);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let sidebar_chunk = Rect {
|
|
|
|
|
+ x: content_area.x + content_area.width.saturating_sub(sidebar_width),
|
|
|
|
|
+ y: content_area.y,
|
|
|
|
|
+ width: sidebar_width,
|
|
|
|
|
+ height: content_area.height,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let (scratchpad_chunk, results_chunk) = if app.show_notes_panel {
|
|
|
|
|
+ let center_width = content_area.width.saturating_sub(sidebar_width);
|
|
|
|
|
+ let desired_scratchpad = content_area.width.saturating_mul(SCRATCHPAD_TARGET_WIDTH_PERCENT) / 100;
|
|
|
|
|
+ let mut scratchpad_width = clamp_panel_size(
|
|
|
|
|
+ desired_scratchpad,
|
|
|
|
|
+ SCRATCHPAD_MIN_WIDTH,
|
|
|
|
|
+ SCRATCHPAD_MAX_WIDTH,
|
|
|
|
|
+ )
|
|
|
|
|
+ .min(center_width.saturating_sub(RESULTS_MIN_WIDTH));
|
|
|
|
|
+ if scratchpad_width == 0 {
|
|
|
|
|
+ scratchpad_width = SCRATCHPAD_MIN_WIDTH.min(center_width);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ (
|
|
|
|
|
+ Some(Rect {
|
|
|
|
|
+ x: content_area.x,
|
|
|
|
|
+ y: content_area.y,
|
|
|
|
|
+ width: scratchpad_width,
|
|
|
|
|
+ height: content_area.height,
|
|
|
|
|
+ }),
|
|
|
|
|
+ Rect {
|
|
|
|
|
+ x: content_area.x + scratchpad_width,
|
|
|
|
|
+ y: content_area.y,
|
|
|
|
|
+ width: center_width.saturating_sub(scratchpad_width),
|
|
|
|
|
+ height: content_area.height,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ } else {
|
|
|
|
|
+ (
|
|
|
|
|
+ None,
|
|
|
|
|
+ Rect {
|
|
|
|
|
+ x: content_area.x,
|
|
|
|
|
+ y: content_area.y,
|
|
|
|
|
+ width: content_area.width.saturating_sub(sidebar_width),
|
|
|
|
|
+ height: content_area.height,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let settings_height = clamp_panel_size(
|
|
|
|
|
+ SETTINGS_PANEL_HEIGHT,
|
|
|
|
|
+ SETTINGS_PANEL_MIN_HEIGHT,
|
|
|
|
|
+ SETTINGS_PANEL_MAX_HEIGHT,
|
|
|
|
|
+ )
|
|
|
|
|
+ .min(sidebar_chunk.height.saturating_sub(FAVORITES_MIN_HEIGHT));
|
|
|
|
|
+ let settings_chunk = Rect {
|
|
|
|
|
+ x: sidebar_chunk.x,
|
|
|
|
|
+ y: sidebar_chunk.y + sidebar_chunk.height.saturating_sub(settings_height),
|
|
|
|
|
+ width: sidebar_chunk.width,
|
|
|
|
|
+ height: settings_height,
|
|
|
|
|
+ };
|
|
|
|
|
+ let favorites_chunk = Rect {
|
|
|
|
|
+ x: sidebar_chunk.x,
|
|
|
|
|
+ y: sidebar_chunk.y,
|
|
|
|
|
+ width: sidebar_chunk.width,
|
|
|
|
|
+ height: sidebar_chunk.height.saturating_sub(settings_height),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // store rects for mouse detection
|
|
|
|
|
+ app.panel_rects.topbar = Some(main_chunks[0]);
|
|
|
|
|
+ let help_width = HELP_BUTTON_LABEL.chars().count() as u16;
|
|
|
|
|
+ let export_width = EXPORT_BUTTON_LABEL.chars().count() as u16;
|
|
|
|
|
+ let help_x = main_chunks[0].x + main_chunks[0].width.saturating_sub(help_width);
|
|
|
|
|
+ let export_x = help_x.saturating_sub(export_width + 1);
|
|
|
|
|
+ app.panel_rects.export_button = Some(Rect {
|
|
|
|
|
+ x: export_x,
|
|
|
|
|
+ y: main_chunks[0].y,
|
|
|
|
|
+ width: export_width,
|
|
|
|
|
+ height: 1,
|
|
|
|
|
+ });
|
|
|
|
|
+ app.panel_rects.help_button = Some(Rect {
|
|
|
|
|
+ x: help_x,
|
|
|
|
|
+ y: main_chunks[0].y,
|
|
|
|
|
+ width: help_width,
|
|
|
|
|
+ height: 1,
|
|
|
|
|
+ });
|
|
|
|
|
+ app.panel_rects.dropdown = None;
|
|
|
|
|
+ app.panel_rects.help_popup = None;
|
|
|
|
|
+ app.panel_rects.search_button = None;
|
|
|
|
|
+ app.panel_rects.cancel_button = None;
|
|
|
|
|
+ app.panel_rects.clear_button = None;
|
|
|
|
|
+ app.panel_rects.export_popup = None;
|
|
|
|
|
+ app.panel_rects.export_mode_favorites = None;
|
|
|
|
|
+ app.panel_rects.export_mode_results = None;
|
|
|
|
|
+ app.panel_rects.export_path = None;
|
|
|
|
|
+ app.panel_rects.export_cancel = None;
|
|
|
|
|
+ app.panel_rects.export_save = None;
|
|
|
|
|
+ app.panel_rects.scratchpad = scratchpad_chunk;
|
|
|
|
|
+ app.panel_rects.results = Some(results_chunk);
|
|
|
|
|
+ app.panel_rects.favorites = Some(favorites_chunk);
|
|
|
|
|
+ app.panel_rects.settings = Some(settings_chunk);
|
|
|
|
|
+ app.panel_rects.search = Some(main_chunks[2]);
|
|
|
|
|
+
|
|
|
|
|
+ // draw each panel
|
|
|
|
|
+ draw_topbar(f, main_chunks[0]);
|
|
|
|
|
+ if let Some(scratchpad_rect) = scratchpad_chunk {
|
|
|
|
|
+ draw_scratchpad(f, app, scratchpad_rect);
|
|
|
|
|
+ }
|
|
|
|
|
+ draw_results(f, app, results_chunk);
|
|
|
|
|
+ draw_favorites(f, app, favorites_chunk);
|
|
|
|
|
+ draw_settings(f, app, settings_chunk);
|
|
|
|
|
+ draw_search(f, app, main_chunks[2]);
|
|
|
|
|
+
|
|
|
|
|
+ // draw dropdown overlay if open
|
|
|
|
|
+ if let DropdownState::Open(selected) = &app.dropdown {
|
|
|
|
|
+ draw_dropdown(f, app, settings_chunk, *selected);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if app.show_help {
|
|
|
|
|
+ draw_help_overlay(f, app, size);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if app.export_popup.is_some() {
|
|
|
|
|
+ draw_export_popup(f, app, size);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn terminal_too_small(area: Rect) -> bool {
|
|
|
|
|
+ area.width < MIN_UI_WIDTH || area.height < MIN_UI_HEIGHT
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn draw_terminal_too_small(f: &mut Frame, area: Rect) {
|
|
|
|
|
+ let block = Block::default()
|
|
|
|
|
+ .borders(Borders::ALL)
|
|
|
|
|
+ .border_style(Style::default().fg(Color::Red))
|
|
|
|
|
+ .title(" hoardom ");
|
|
|
|
|
+
|
|
|
|
|
+ let inner = block.inner(area);
|
|
|
|
|
+ f.render_widget(block, area);
|
|
|
|
|
+ let content_width = inner.width as usize;
|
|
|
|
|
+
|
|
|
|
|
+ let text = vec![
|
|
|
|
|
+ Line::from(Span::styled(
|
|
|
|
|
+ fit_cell_center("HELP ! HELP ! HELP !", content_width),
|
|
|
|
|
+ Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
|
|
|
|
+ )),
|
|
|
|
|
+ Line::from(Span::styled(
|
|
|
|
|
+ fit_cell_center("I AM BEING CRUSHED!", content_width),
|
|
|
|
|
+ Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
|
|
|
|
+ )),
|
|
|
|
|
+ Line::from(fit_cell_center("", content_width)),
|
|
|
|
|
+ Line::from(Span::styled(
|
|
|
|
|
+ fit_cell_center("Im claustrophobic! :'(", content_width),
|
|
|
|
|
+ Style::default().fg(Color::White),
|
|
|
|
|
+ )),
|
|
|
|
|
+ Line::from(Span::styled(
|
|
|
|
|
+ fit_cell_center(&format!("Need {}x{} of space", MIN_UI_WIDTH, MIN_UI_HEIGHT), content_width),
|
|
|
|
|
+ Style::default().fg(Color::White),
|
|
|
|
|
+ )),
|
|
|
|
|
+ Line::from(Span::styled(
|
|
|
|
|
+ fit_cell_center(&format!("Current: {}x{}", area.width, area.height), content_width),
|
|
|
|
|
+ Style::default().fg(Color::DarkGray),
|
|
|
|
|
+ )),
|
|
|
|
|
+ Line::from(fit_cell_center("", content_width)),
|
|
|
|
|
+ Line::from(Span::styled(
|
|
|
|
|
+ fit_cell_center("REFUSING TO WORK TILL YOU", content_width),
|
|
|
|
|
+ Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
|
|
|
|
+ )),
|
|
|
|
|
+ Line::from(Span::styled(
|
|
|
|
|
+ fit_cell_center("GIVE ME BACK MY SPACE! >:(", content_width),
|
|
|
|
|
+ Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
|
|
|
|
+ )),
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ f.render_widget(Paragraph::new(text), inner);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn draw_topbar(f: &mut Frame, area: Rect) {
|
|
|
|
|
+ let title = format!("{} - {}", APP_NAME, APP_DESC);
|
|
|
|
|
+ let width = area.width as usize;
|
|
|
|
|
+ let left = format!("{} {}", CLOSE_BUTTON_LABEL, title);
|
|
|
|
|
+ let right = format!("{} {}", EXPORT_BUTTON_LABEL, HELP_BUTTON_LABEL);
|
|
|
|
|
+ let gap = width.saturating_sub(left.chars().count() + right.chars().count());
|
|
|
|
|
+ let paragraph = Paragraph::new(Line::from(vec![
|
|
|
|
|
+ Span::styled(CLOSE_BUTTON_LABEL, Style::default().fg(Color::Red).bg(Color::Gray).add_modifier(Modifier::BOLD)),
|
|
|
|
|
+ Span::styled(format!(" {}", title), Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
|
|
|
+ Span::styled(" ".repeat(gap), Style::default().bg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
|
|
|
+ Span::styled(EXPORT_BUTTON_LABEL, Style::default().fg(Color::LightGreen).bg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
|
|
|
+ Span::styled(" ", Style::default().bg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
|
|
|
+ Span::styled(HELP_BUTTON_LABEL, Style::default().fg(Color::LightGreen).bg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
|
|
|
+ ]))
|
|
|
|
|
+ .style(Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD));
|
|
|
|
|
+ f.render_widget(paragraph, area);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Help Overlay size is here you goofus
|
|
|
|
|
+fn draw_help_overlay(f: &mut Frame, app: &mut App, area: Rect) {
|
|
|
|
|
+ let popup = help_popup_rect(area);
|
|
|
|
|
+ app.panel_rects.help_popup = Some(popup);
|
|
|
|
|
+
|
|
|
|
|
+ let text = vec![
|
|
|
|
|
+ Line::from(Span::styled(" ", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("Global :", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("F1 or Help button Toggle this help", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("F2 or Export button Open export popup", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("Ctrl+C Quit the app", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("s Stop/cancel running search", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("Esc Clear selection or close help", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("Tab or Shift+Tab Move between panels", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("Up and Down arrows Navigate results", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled(" ", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("Mouse Click Elements duh", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("Scrolling Scroll through elements (yea)", Style::default().fg(Color::White))),
|
|
|
|
|
+
|
|
|
|
|
+ Line::from(Span::styled(" ", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("In Results :", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("Enter Add highlighted result to Favorites", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("In Favorites :", Style::default().fg(Color::White))),
|
|
|
|
|
+ Line::from(Span::styled("Backspace or Delete Remove focused favorite", Style::default().fg(Color::White))),
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ let block = Block::default()
|
|
|
|
|
+ .borders(Borders::ALL)
|
|
|
|
|
+ .border_style(Style::default().fg(Color::Red))
|
|
|
|
|
+ .title(" Help ");
|
|
|
|
|
+
|
|
|
|
|
+ f.render_widget(Clear, popup);
|
|
|
|
|
+ f.render_widget(Paragraph::new(text).block(block), popup);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn draw_export_popup(f: &mut Frame, app: &mut App, area: Rect) {
|
|
|
|
|
+ let Some(popup_state) = app.export_popup.as_ref() else {
|
|
|
|
|
+ return;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let popup = export_popup_rect(area);
|
|
|
|
|
+ app.panel_rects.export_popup = Some(popup);
|
|
|
|
|
+
|
|
|
|
|
+ let block = Block::default()
|
|
|
|
|
+ .borders(Borders::ALL)
|
|
|
|
|
+ .border_style(Style::default().fg(Color::Red))
|
|
|
|
|
+ .title(" Export ");
|
|
|
|
|
+
|
|
|
|
|
+ let inner = block.inner(popup);
|
|
|
|
|
+ f.render_widget(Clear, popup);
|
|
|
|
|
+ f.render_widget(block, popup);
|
|
|
|
|
+
|
|
|
|
|
+ let chunks = Layout::default()
|
|
|
|
|
+ .direction(Direction::Vertical)
|
|
|
|
|
+ .constraints([
|
|
|
|
|
+ Constraint::Length(1),
|
|
|
|
|
+ Constraint::Length(1),
|
|
|
|
|
+ Constraint::Length(3),
|
|
|
|
|
+ Constraint::Length(1),
|
|
|
|
|
+ Constraint::Length(1),
|
|
|
|
|
+ Constraint::Min(0),
|
|
|
|
|
+ ])
|
|
|
|
|
+ .split(inner);
|
|
|
|
|
+
|
|
|
|
|
+ let mode_style = |mode: ExportMode| {
|
|
|
|
|
+ let mut style = Style::default().fg(Color::White);
|
|
|
|
|
+ if popup_state.mode == mode {
|
|
|
|
|
+ style = style.add_modifier(Modifier::REVERSED | Modifier::BOLD);
|
|
|
|
|
+ } else if popup_state.selected_row == 0 {
|
|
|
|
|
+ style = style.add_modifier(Modifier::BOLD);
|
|
|
|
|
+ }
|
|
|
|
|
+ style
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let subtitle = fit_cell_center("Choose what to export and where to save it.", chunks[0].width as usize);
|
|
|
|
|
+ f.render_widget(
|
|
|
|
|
+ Paragraph::new(subtitle).style(Style::default().fg(Color::DarkGray)),
|
|
|
|
|
+ chunks[0],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ let favorites_label = "[Favorites TXT]";
|
|
|
|
|
+ let results_label = "[Results CSV]";
|
|
|
|
|
+ let mode_spacing = " ";
|
|
|
|
|
+ let mode_text = format!("{}{}{}", favorites_label, mode_spacing, results_label);
|
|
|
|
|
+ let mode_pad = chunks[1]
|
|
|
|
|
+ .width
|
|
|
|
|
+ .saturating_sub(mode_text.chars().count() as u16)
|
|
|
|
|
+ / 2;
|
|
|
|
|
+ let mode_x = chunks[1].x + mode_pad;
|
|
|
|
|
+ app.panel_rects.export_mode_favorites = Some(Rect {
|
|
|
|
|
+ x: mode_x,
|
|
|
|
|
+ y: chunks[1].y,
|
|
|
|
|
+ width: favorites_label.chars().count() as u16,
|
|
|
|
|
+ height: 1,
|
|
|
|
|
+ });
|
|
|
|
|
+ app.panel_rects.export_mode_results = Some(Rect {
|
|
|
|
|
+ x: mode_x + favorites_label.chars().count() as u16 + mode_spacing.chars().count() as u16,
|
|
|
|
|
+ y: chunks[1].y,
|
|
|
|
|
+ width: results_label.chars().count() as u16,
|
|
|
|
|
+ height: 1,
|
|
|
|
|
+ });
|
|
|
|
|
+ let mode_line = Line::from(vec![
|
|
|
|
|
+ Span::raw(" ".repeat(mode_pad as usize)),
|
|
|
|
|
+ Span::styled(favorites_label, mode_style(ExportMode::FavoritesTxt)),
|
|
|
|
|
+ Span::raw(mode_spacing),
|
|
|
|
|
+ Span::styled(results_label, mode_style(ExportMode::ResultsCsv)),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ f.render_widget(Paragraph::new(mode_line), chunks[1]);
|
|
|
|
|
+
|
|
|
|
|
+ let path_block = Block::default()
|
|
|
|
|
+ .borders(Borders::ALL)
|
|
|
|
|
+ .border_style(if popup_state.selected_row == 1 {
|
|
|
|
|
+ Style::default().fg(Color::Red)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::DarkGray)
|
|
|
|
|
+ })
|
|
|
|
|
+ .title(" Save to ");
|
|
|
|
|
+ let path_inner = path_block.inner(chunks[2]);
|
|
|
|
|
+ app.panel_rects.export_path = Some(chunks[2]);
|
|
|
|
|
+ f.render_widget(path_block, chunks[2]);
|
|
|
|
|
+ f.render_widget(
|
|
|
|
|
+ Paragraph::new(popup_state.path.as_str()).style(Style::default().fg(Color::White)),
|
|
|
|
|
+ path_inner,
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ let status_style = if popup_state.status_success {
|
|
|
|
|
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
|
|
|
|
+ } else if popup_state.confirm_overwrite {
|
|
|
|
|
+ Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
|
|
|
|
+ } else if popup_state.status.is_some() {
|
|
|
|
|
+ Style::default().fg(Color::Red)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::DarkGray)
|
|
|
|
|
+ };
|
|
|
|
|
+ let status_text = popup_state.status.as_deref().unwrap_or(" ");
|
|
|
|
|
+ f.render_widget(
|
|
|
|
|
+ Paragraph::new(fit_cell_center(status_text, chunks[3].width as usize)).style(status_style),
|
|
|
|
|
+ chunks[3],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ let cancel_label = "[Cancel]";
|
|
|
|
|
+ let button_gap = " ";
|
|
|
|
|
+ let save_label = "[Save]";
|
|
|
|
|
+ let button_text = format!("{}{}{}", cancel_label, button_gap, save_label);
|
|
|
|
|
+ let button_pad = chunks[4]
|
|
|
|
|
+ .width
|
|
|
|
|
+ .saturating_sub(button_text.chars().count() as u16);
|
|
|
|
|
+ let buttons_x = chunks[4].x + button_pad;
|
|
|
|
|
+ app.panel_rects.export_cancel = Some(Rect {
|
|
|
|
|
+ x: buttons_x,
|
|
|
|
|
+ y: chunks[4].y,
|
|
|
|
|
+ width: cancel_label.chars().count() as u16,
|
|
|
|
|
+ height: 1,
|
|
|
|
|
+ });
|
|
|
|
|
+ app.panel_rects.export_save = Some(Rect {
|
|
|
|
|
+ x: buttons_x + cancel_label.chars().count() as u16 + button_gap.chars().count() as u16,
|
|
|
|
|
+ y: chunks[4].y,
|
|
|
|
|
+ width: save_label.chars().count() as u16,
|
|
|
|
|
+ height: 1,
|
|
|
|
|
+ });
|
|
|
|
|
+ let button_line = Line::from(vec![
|
|
|
|
|
+ Span::raw(" ".repeat(button_pad as usize)),
|
|
|
|
|
+ Span::styled(
|
|
|
|
|
+ cancel_label,
|
|
|
|
|
+ if popup_state.selected_row == 2 {
|
|
|
|
|
+ Style::default().fg(Color::Green).bg(Color::DarkGray).add_modifier(Modifier::BOLD)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
|
|
|
|
+ },
|
|
|
|
|
+ ),
|
|
|
|
|
+ Span::raw(button_gap),
|
|
|
|
|
+ Span::styled(
|
|
|
|
|
+ save_label,
|
|
|
|
|
+ if popup_state.selected_row == 3 {
|
|
|
|
|
+ Style::default().fg(Color::Green).bg(Color::DarkGray).add_modifier(Modifier::BOLD)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
|
|
|
|
+ },
|
|
|
|
|
+ ),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ f.render_widget(Paragraph::new(button_line), chunks[4]);
|
|
|
|
|
+
|
|
|
|
|
+ if popup_state.selected_row == 1 {
|
|
|
|
|
+ let max_x = path_inner.width.saturating_sub(1);
|
|
|
|
|
+ let x = path_inner.x + (popup_state.cursor_pos as u16).min(max_x);
|
|
|
|
|
+ let y = path_inner.y;
|
|
|
|
|
+ f.set_cursor_position((x, y));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn draw_results(f: &mut Frame, app: &mut App, area: Rect) {
|
|
|
|
|
+ let focused = app.focus == Focus::Results;
|
|
|
|
|
+ let border_style = if focused {
|
|
|
|
|
+ Style::default().fg(Color::Red)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::DarkGray)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // show progress in title when searching
|
|
|
|
|
+ let title = if app.searching {
|
|
|
|
|
+ let (cur, tot) = app.search_progress;
|
|
|
|
|
+ if tot > 0 {
|
|
|
|
|
+ format!(" Results [{}/{}] ", cur, tot)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ " Results (loading...) ".to_string()
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if app.results.is_empty() {
|
|
|
|
|
+ " Results ".to_string()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let avail = app.results.iter().filter(|(_, r)| r.is_available()).count();
|
|
|
|
|
+ let duration_str = match app.last_search_duration {
|
|
|
|
|
+ Some(d) => format!(" | Took: {:.1}s", d.as_secs_f64()),
|
|
|
|
|
+ None => String::new(),
|
|
|
|
|
+ };
|
|
|
|
|
+ format!(" Results ({} available / {} total{}) ", avail, app.results.len(), duration_str)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let block = Block::default()
|
|
|
|
|
+ .borders(Borders::ALL)
|
|
|
|
|
+ .border_style(border_style)
|
|
|
|
|
+ .title(title);
|
|
|
|
|
+
|
|
|
|
|
+ // If searching and have progress, show a gauge bar at top of results area
|
|
|
|
|
+ if app.searching && app.search_progress.1 > 0 {
|
|
|
|
|
+ let inner = block.inner(area);
|
|
|
|
|
+ f.render_widget(block, area);
|
|
|
|
|
+
|
|
|
|
|
+ // split: 1 line for progress bar, rest for results
|
|
|
|
|
+ let chunks = Layout::default()
|
|
|
|
|
+ .direction(Direction::Vertical)
|
|
|
|
|
+ .constraints([Constraint::Length(1), Constraint::Min(1)])
|
|
|
|
|
+ .split(inner);
|
|
|
|
|
+
|
|
|
|
|
+ // draw progress gauge
|
|
|
|
|
+ let (cur, tot) = app.search_progress;
|
|
|
|
|
+ let pct = (cur as f64 / tot as f64 * 100.0) as u16;
|
|
|
|
|
+ let filled = (chunks[0].width as u32 * cur as u32 / tot as u32) as u16;
|
|
|
|
|
+ let bar: String = "\u{2588}".repeat(filled as usize)
|
|
|
|
|
+ + &"\u{2591}".repeat((chunks[0].width.saturating_sub(filled)) as usize);
|
|
|
|
|
+ let bar_text = format!(" {}% ", pct);
|
|
|
|
|
+ let gauge_line = Line::from(vec![
|
|
|
|
|
+ Span::styled(bar, Style::default().fg(Color::Red)),
|
|
|
|
|
+ Span::styled(bar_text, Style::default().fg(Color::DarkGray)),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ f.render_widget(Paragraph::new(gauge_line), chunks[0]);
|
|
|
|
|
+
|
|
|
|
|
+ // draw results list in remaining space
|
|
|
|
|
+ draw_results_list(f, app, chunks[1]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let inner = block.inner(area);
|
|
|
|
|
+ f.render_widget(block, area);
|
|
|
|
|
+ draw_results_list(f, app, inner);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn draw_results_list(f: &mut Frame, app: &mut App, area: Rect) {
|
|
|
|
|
+ let show_note_column = app.show_unavailable;
|
|
|
|
|
+ let selected_idx = app.results_state.selected();
|
|
|
|
|
+ let selected_bg = Color::Black;
|
|
|
|
|
+
|
|
|
|
|
+ // collect visible results
|
|
|
|
|
+ let visible_data: Vec<(String, String, String, DomainStatus)> = if app.show_unavailable {
|
|
|
|
|
+ app.results.iter().map(|(_, r)| (r.full.clone(), r.status_str().to_string(), r.note_str(), r.status.clone())).collect()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ app.results.iter().filter(|(_, r)| r.is_available()).map(|(_, r)| (r.full.clone(), r.status_str().to_string(), r.note_str(), r.status.clone())).collect()
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if visible_data.is_empty() && !app.searching {
|
|
|
|
|
+ let msg = if app.results.is_empty() {
|
|
|
|
|
+ "Type a domain suffix or full domain name then press Enter"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ "No available domains found"
|
|
|
|
|
+ };
|
|
|
|
|
+ let p = Paragraph::new(msg).style(Style::default().fg(Color::DarkGray));
|
|
|
|
|
+ f.render_widget(p, area);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // calculate adaptive column widths from available space
|
|
|
|
|
+ let total_w = area.width.saturating_sub(1) as usize;
|
|
|
|
|
+ let marker_w = 3usize;
|
|
|
|
|
+ let sep_w = if show_note_column { 9usize } else { 6usize };
|
|
|
|
|
+ let mut status_w = 10usize.min(total_w.saturating_sub(12)).max(6);
|
|
|
|
|
+ let mut note_w = if show_note_column {
|
|
|
|
|
+ (total_w / 3).clamp(8, 28)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ 0
|
|
|
|
|
+ };
|
|
|
|
|
+ let mut domain_w = total_w.saturating_sub(status_w + note_w + marker_w + sep_w);
|
|
|
|
|
+
|
|
|
|
|
+ if domain_w < 10 {
|
|
|
|
|
+ let needed = 10 - domain_w;
|
|
|
|
|
+ if show_note_column {
|
|
|
|
|
+ let shrink_note = needed.min(note_w.saturating_sub(8));
|
|
|
|
|
+ note_w = note_w.saturating_sub(shrink_note);
|
|
|
|
|
+ }
|
|
|
|
|
+ domain_w = total_w.saturating_sub(status_w + note_w + marker_w + sep_w);
|
|
|
|
|
+ }
|
|
|
|
|
+ if domain_w < 10 {
|
|
|
|
|
+ let needed = 10 - domain_w;
|
|
|
|
|
+ let shrink_status = needed.min(status_w.saturating_sub(6));
|
|
|
|
|
+ status_w = status_w.saturating_sub(shrink_status);
|
|
|
|
|
+ domain_w = total_w.saturating_sub(status_w + note_w + marker_w + sep_w);
|
|
|
|
|
+ }
|
|
|
|
|
+ domain_w = domain_w.max(6);
|
|
|
|
|
+
|
|
|
|
|
+ let (list_area, header_area) = if !visible_data.is_empty() {
|
|
|
|
|
+ let chunks = Layout::default()
|
|
|
|
|
+ .direction(Direction::Vertical)
|
|
|
|
|
+ .constraints([Constraint::Length(1), Constraint::Min(1)])
|
|
|
|
|
+ .split(area);
|
|
|
|
|
+ (chunks[1], Some(chunks[0]))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ (area, None)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(header_area) = header_area {
|
|
|
|
|
+ let mut header_spans = vec![
|
|
|
|
|
+ Span::styled(format!(" {}", fit_cell("Domain", domain_w)), Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD)),
|
|
|
|
|
+ Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
|
|
|
|
|
+ Span::styled(fit_cell("Status", status_w), Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD)),
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ if show_note_column {
|
|
|
|
|
+ header_spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray)));
|
|
|
|
|
+ header_spans.push(Span::styled(fit_cell("Details", note_w), Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD)));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ header_spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray)));
|
|
|
|
|
+ header_spans.push(Span::styled(" ✓ ", Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD)));
|
|
|
|
|
+
|
|
|
|
|
+ f.render_widget(Paragraph::new(Line::from(header_spans)), header_area);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let items: Vec<ListItem> = visible_data
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .enumerate()
|
|
|
|
|
+ .map(|(idx, (full, status_str, note, status))| {
|
|
|
|
|
+ let is_selected = selected_idx == Some(idx);
|
|
|
|
|
+ let selection_bg = if is_selected { Some(selected_bg) } else { None };
|
|
|
|
|
+
|
|
|
|
|
+ let status_style = match status {
|
|
|
|
|
+ DomainStatus::Available => Style::default().fg(Color::Green),
|
|
|
|
|
+ DomainStatus::Registered { .. } => Style::default().fg(Color::Red),
|
|
|
|
|
+ DomainStatus::Error { kind, .. } => match kind {
|
|
|
|
|
+ ErrorKind::InvalidTld => Style::default().fg(Color::Yellow),
|
|
|
|
|
+ _ => Style::default().fg(Color::Blue),
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let domain_style = match status {
|
|
|
|
|
+ DomainStatus::Available => Style::default().fg(Color::Green),
|
|
|
|
|
+ DomainStatus::Registered { .. } => Style::default().fg(Color::Red),
|
|
|
|
|
+ DomainStatus::Error { kind, .. } => match kind {
|
|
|
|
|
+ ErrorKind::InvalidTld => Style::default().fg(Color::Yellow),
|
|
|
|
|
+ _ => Style::default().fg(Color::Blue),
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let apply_bg = |style: Style| {
|
|
|
|
|
+ if let Some(bg) = selection_bg {
|
|
|
|
|
+ style.bg(bg).add_modifier(Modifier::BOLD)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ style
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let mut spans = vec![
|
|
|
|
|
+ Span::styled(format!(" {}", fit_cell(full, domain_w)), apply_bg(domain_style)),
|
|
|
|
|
+ Span::styled(" \u{2502} ", apply_bg(Style::default().fg(Color::Gray))),
|
|
|
|
|
+ Span::styled(fit_cell(status_str, status_w), apply_bg(status_style)),
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ if show_note_column {
|
|
|
|
|
+ spans.push(Span::styled(" \u{2502} ", apply_bg(Style::default().fg(Color::Gray))));
|
|
|
|
|
+ spans.push(Span::styled(fit_cell(note, note_w), apply_bg(Style::default().fg(Color::White))));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ spans.push(Span::styled(" \u{2502} ", apply_bg(Style::default().fg(Color::Gray))));
|
|
|
|
|
+ spans.push(match status {
|
|
|
|
|
+ DomainStatus::Available => Span::styled(" ✓ ", apply_bg(Style::default().fg(Color::Green))),
|
|
|
|
|
+ DomainStatus::Registered { .. } => Span::styled(" ✗ ", apply_bg(Style::default().fg(Color::Red))),
|
|
|
|
|
+ DomainStatus::Error { kind, .. } => match kind {
|
|
|
|
|
+ ErrorKind::InvalidTld => Span::styled(" ? ", apply_bg(Style::default().fg(Color::Yellow))),
|
|
|
|
|
+ _ => Span::styled(" ! ", apply_bg(Style::default().fg(Color::Blue))),
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ let line = Line::from(spans);
|
|
|
|
|
+ ListItem::new(line)
|
|
|
|
|
+ })
|
|
|
|
|
+ .collect();
|
|
|
|
|
+
|
|
|
|
|
+ let list = List::new(items);
|
|
|
|
|
+
|
|
|
|
|
+ f.render_stateful_widget(list, list_area, &mut app.results_state);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn fit_cell(value: &str, width: usize) -> String {
|
|
|
|
|
+ if width == 0 {
|
|
|
|
|
+ return String::new();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let len = value.chars().count();
|
|
|
|
|
+ if len <= width {
|
|
|
|
|
+ return format!("{:<width$}", value, width = width);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if width == 1 {
|
|
|
|
|
+ return "…".to_string();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let truncated: String = value.chars().take(width - 1).collect();
|
|
|
|
|
+ format!("{:<width$}", format!("{}…", truncated), width = width)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn fit_cell_center(value: &str, width: usize) -> String {
|
|
|
|
|
+ if width == 0 {
|
|
|
|
|
+ return String::new();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let rendered = if value.chars().count() <= width {
|
|
|
|
|
+ value.to_string()
|
|
|
|
|
+ } else if width == 1 {
|
|
|
|
|
+ "…".to_string()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let truncated: String = value.chars().take(width - 1).collect();
|
|
|
|
|
+ format!("{}…", truncated)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let len = rendered.chars().count();
|
|
|
|
|
+ let left = width.saturating_sub(len) / 2;
|
|
|
|
|
+ let right = width.saturating_sub(len + left);
|
|
|
|
|
+ format!("{}{}{}", " ".repeat(left), rendered, " ".repeat(right))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn wrap_text_lines(text: &str, width: u16) -> Vec<String> {
|
|
|
|
|
+ if width == 0 {
|
|
|
|
|
+ return Vec::new();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let width = width as usize;
|
|
|
|
|
+ let mut wrapped = Vec::new();
|
|
|
|
|
+
|
|
|
|
|
+ for line in text.split('\n') {
|
|
|
|
|
+ if line.is_empty() {
|
|
|
|
|
+ wrapped.push(String::new());
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let chars: Vec<char> = line.chars().collect();
|
|
|
|
|
+ for chunk in chars.chunks(width) {
|
|
|
|
|
+ wrapped.push(chunk.iter().collect());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if text.ends_with('\n') {
|
|
|
|
|
+ wrapped.push(String::new());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ wrapped
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn scratchpad_cursor_positions(text: &str, width: u16) -> Vec<(usize, u16, u16)> {
|
|
|
|
|
+ if width == 0 {
|
|
|
|
|
+ return vec![(0, 0, 0)];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let mut positions = Vec::with_capacity(text.chars().count() + 1);
|
|
|
|
|
+ let mut line = 0u16;
|
|
|
|
|
+ let mut col = 0u16;
|
|
|
|
|
+ let mut byte_idx = 0usize;
|
|
|
|
|
+ positions.push((byte_idx, line, col));
|
|
|
|
|
+
|
|
|
|
|
+ for ch in text.chars() {
|
|
|
|
|
+ byte_idx += ch.len_utf8();
|
|
|
|
|
+ if ch == '\n' {
|
|
|
|
|
+ line += 1;
|
|
|
|
|
+ col = 0;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ col += 1;
|
|
|
|
|
+ if col >= width {
|
|
|
|
|
+ line += 1;
|
|
|
|
|
+ col = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ positions.push((byte_idx, line, col));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ positions
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn cursor_line_col(text: &str, cursor: usize, width: u16) -> (u16, u16) {
|
|
|
|
|
+ let clamped_cursor = cursor.min(text.len());
|
|
|
|
|
+ scratchpad_cursor_positions(text, width)
|
|
|
|
|
+ .into_iter()
|
|
|
|
|
+ .take_while(|(idx, _, _)| *idx <= clamped_cursor)
|
|
|
|
|
+ .last()
|
|
|
|
|
+ .map(|(_, line, col)| (line, col))
|
|
|
|
|
+ .unwrap_or((0, 0))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn cursor_index_for_line_col(text: &str, target_line: u16, target_col: u16, width: u16) -> usize {
|
|
|
|
|
+ let positions = scratchpad_cursor_positions(text, width);
|
|
|
|
|
+ let mut best_on_line = None;
|
|
|
|
|
+
|
|
|
|
|
+ for (idx, line, col) in positions {
|
|
|
|
|
+ if line == target_line {
|
|
|
|
|
+ best_on_line = Some(idx);
|
|
|
|
|
+ if col >= target_col {
|
|
|
|
|
+ return idx;
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if line > target_line {
|
|
|
|
|
+ return best_on_line.unwrap_or(idx);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ best_on_line.unwrap_or(text.len())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn scratchpad_inner_width(app: &App) -> u16 {
|
|
|
|
|
+ app.panel_rects
|
|
|
|
|
+ .scratchpad
|
|
|
|
|
+ .map(|rect| rect.width.saturating_sub(2).max(1))
|
|
|
|
|
+ .unwrap_or(1)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn move_scratchpad_cursor_vertical(app: &mut App, line_delta: i16) {
|
|
|
|
|
+ let width = scratchpad_inner_width(app);
|
|
|
|
|
+ let (line, col) = cursor_line_col(&app.scratchpad, app.scratchpad_cursor, width);
|
|
|
|
|
+ let target_line = if line_delta.is_negative() {
|
|
|
|
|
+ line.saturating_sub(line_delta.unsigned_abs())
|
|
|
|
|
+ } else {
|
|
|
|
|
+ line.saturating_add(line_delta as u16)
|
|
|
|
|
+ };
|
|
|
|
|
+ app.scratchpad_cursor = cursor_index_for_line_col(&app.scratchpad, target_line, col, width);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn draw_scratchpad(f: &mut Frame, app: &mut App, area: Rect) {
|
|
|
|
|
+ let focused = app.focus == Focus::Scratchpad;
|
|
|
|
|
+ let border_style = if focused {
|
|
|
|
|
+ Style::default().fg(Color::Red)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::DarkGray)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let block = Block::default()
|
|
|
|
|
+ .borders(Borders::ALL)
|
|
|
|
|
+ .border_style(border_style)
|
|
|
|
|
+ .title(" Scratchpad ");
|
|
|
|
|
+
|
|
|
|
|
+ let inner = block.inner(area);
|
|
|
|
|
+ let wrapped_lines = wrap_text_lines(&app.scratchpad, inner.width);
|
|
|
|
|
+ let text: Vec<Line> = if wrapped_lines.is_empty() {
|
|
|
|
|
+ vec![Line::raw(String::new())]
|
|
|
|
|
+ } else {
|
|
|
|
|
+ wrapped_lines.into_iter().map(Line::raw).collect()
|
|
|
|
|
+ };
|
|
|
|
|
+ f.render_widget(block, area);
|
|
|
|
|
+ f.render_widget(
|
|
|
|
|
+ Paragraph::new(text)
|
|
|
|
|
+ .style(Style::default().fg(Color::White)),
|
|
|
|
|
+ inner,
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if focused && app.export_popup.is_none() {
|
|
|
|
|
+ let (line, col) = cursor_line_col(&app.scratchpad, app.scratchpad_cursor, inner.width);
|
|
|
|
|
+ let max_x = inner.width.saturating_sub(1);
|
|
|
|
|
+ let max_y = inner.height.saturating_sub(1);
|
|
|
|
|
+ f.set_cursor_position((inner.x + col.min(max_x), inner.y + line.min(max_y)));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn draw_favorites(f: &mut Frame, app: &mut App, area: Rect) {
|
|
|
|
|
+ let focused = app.focus == Focus::Favorites;
|
|
|
|
|
+ let border_style = if focused {
|
|
|
|
|
+ Style::default().fg(Color::Red)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::DarkGray)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let title = if app.checking_favorites {
|
|
|
|
|
+ " Favorites (checking...) "
|
|
|
|
|
+ } else {
|
|
|
|
|
+ " Favorites "
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let block = Block::default()
|
|
|
|
|
+ .borders(Borders::ALL)
|
|
|
|
|
+ .border_style(border_style)
|
|
|
|
|
+ .title(title);
|
|
|
|
|
+
|
|
|
|
|
+ let inner = block.inner(area);
|
|
|
|
|
+
|
|
|
|
|
+ // Reserve 1 row at the bottom for the check button
|
|
|
|
|
+ let list_area = Rect {
|
|
|
|
|
+ x: inner.x,
|
|
|
|
|
+ y: inner.y,
|
|
|
|
|
+ width: inner.width,
|
|
|
|
|
+ height: inner.height.saturating_sub(1),
|
|
|
|
|
+ };
|
|
|
|
|
+ let button_area = Rect {
|
|
|
|
|
+ x: inner.x,
|
|
|
|
|
+ y: inner.y + list_area.height,
|
|
|
|
|
+ width: inner.width,
|
|
|
|
|
+ height: 1.min(inner.height),
|
|
|
|
|
+ };
|
|
|
|
|
+ app.panel_rects.fav_check_button = Some(button_area);
|
|
|
|
|
+
|
|
|
|
|
+ let items: Vec<ListItem> = app
|
|
|
|
|
+ .favorites
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .map(|fav| {
|
|
|
|
|
+ let status_color = match fav.status.as_str() {
|
|
|
|
|
+ "available" => Color::Green,
|
|
|
|
|
+ "registered" => Color::Red,
|
|
|
|
|
+ "error" => Color::DarkGray,
|
|
|
|
|
+ _ => Color::White, // unknown
|
|
|
|
|
+ };
|
|
|
|
|
+ let mut spans = vec![Span::styled(
|
|
|
|
|
+ fav.domain.as_str(),
|
|
|
|
|
+ Style::default().fg(status_color),
|
|
|
|
|
+ )];
|
|
|
|
|
+ if fav.changed {
|
|
|
|
|
+ spans.push(Span::styled(" !", Style::default().fg(Color::Yellow)));
|
|
|
|
|
+ }
|
|
|
|
|
+ ListItem::new(Line::from(spans))
|
|
|
|
|
+ })
|
|
|
|
|
+ .collect();
|
|
|
|
|
+
|
|
|
|
|
+ let list = List::new(items)
|
|
|
|
|
+ .highlight_style(
|
|
|
|
|
+ Style::default()
|
|
|
|
|
+ .add_modifier(Modifier::REVERSED),
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ f.render_widget(block, area);
|
|
|
|
|
+ f.render_stateful_widget(list, list_area, &mut app.favorites_state);
|
|
|
|
|
+
|
|
|
|
|
+ // Draw the check button at the bottom
|
|
|
|
|
+ let btn_label = if app.checking_favorites { "checking..." } else { "[c]heck all" };
|
|
|
|
|
+ let btn_style = if app.checking_favorites {
|
|
|
|
|
+ Style::default().fg(Color::DarkGray)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::Green)
|
|
|
|
|
+ };
|
|
|
|
|
+ f.render_widget(
|
|
|
|
|
+ Paragraph::new(Line::from(Span::styled(btn_label, btn_style)))
|
|
|
|
|
+ .alignment(ratatui::layout::Alignment::Center),
|
|
|
|
|
+ button_area,
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn draw_settings(f: &mut Frame, app: &mut App, area: Rect) {
|
|
|
|
|
+ let focused = app.focus == Focus::Settings;
|
|
|
|
|
+ let border_style = if focused {
|
|
|
|
|
+ Style::default().fg(Color::Red)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::DarkGray)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let block = Block::default()
|
|
|
|
|
+ .borders(Borders::ALL)
|
|
|
|
|
+ .border_style(border_style)
|
|
|
|
|
+ .title(" Settings ");
|
|
|
|
|
+
|
|
|
|
|
+ let unavail_check = if app.show_unavailable { "[x]" } else { "[ ]" };
|
|
|
|
|
+ let notes_check = if app.show_notes_panel { "[x]" } else { "[ ]" };
|
|
|
|
|
+ let clear_check = if app.clear_on_search { "[x]" } else { "[ ]" };
|
|
|
|
|
+ let jobs_str = format!("{:>2}", app.jobs);
|
|
|
|
|
+
|
|
|
|
|
+ let selected = if focused { app.settings_selected } else { None };
|
|
|
|
|
+ let checkbox_style = |row: usize, checked: bool| {
|
|
|
|
|
+ let style = if checked {
|
|
|
|
|
+ Style::default().fg(Color::Green)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::DarkGray)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if selected == Some(row) {
|
|
|
|
|
+ style.add_modifier(Modifier::REVERSED | Modifier::BOLD)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ style
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let label_style = |row: usize| {
|
|
|
|
|
+ if selected == Some(row) {
|
|
|
|
|
+ Style::default().add_modifier(Modifier::REVERSED)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::White)
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let tld_row_style = if selected == Some(0) {
|
|
|
|
|
+ Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default()
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let jobs_row_style = if selected == Some(4) {
|
|
|
|
|
+ Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default()
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let text = vec![
|
|
|
|
|
+ Line::from(vec![
|
|
|
|
|
+ Span::raw(" "),
|
|
|
|
|
+ Span::styled("TLD List: [", tld_row_style.fg(Color::White)),
|
|
|
|
|
+ Span::styled(app.tld_list_name.as_str(), tld_row_style.fg(Color::Cyan)),
|
|
|
|
|
+ Span::styled("] ", tld_row_style.fg(Color::White)),
|
|
|
|
|
+ Span::styled("V", tld_row_style.fg(Color::Green)),
|
|
|
|
|
+ ]),
|
|
|
|
|
+ Line::from(vec![
|
|
|
|
|
+ Span::raw(" "),
|
|
|
|
|
+ Span::styled(unavail_check, checkbox_style(1, app.show_unavailable)),
|
|
|
|
|
+ Span::styled(" Show Unavailable", label_style(1)),
|
|
|
|
|
+ ]),
|
|
|
|
|
+ Line::from(vec![
|
|
|
|
|
+ Span::raw(" "),
|
|
|
|
|
+ Span::styled(notes_check, checkbox_style(2, app.show_notes_panel)),
|
|
|
|
|
+ Span::styled(" Show Notes Panel", label_style(2)),
|
|
|
|
|
+ ]),
|
|
|
|
|
+ Line::from(vec![
|
|
|
|
|
+ Span::raw(" "),
|
|
|
|
|
+ Span::styled(clear_check, checkbox_style(3, app.clear_on_search)),
|
|
|
|
|
+ Span::styled(" Clear on Search", label_style(3)),
|
|
|
|
|
+ ]),
|
|
|
|
|
+ Line::from(vec![
|
|
|
|
|
+ Span::raw(" "),
|
|
|
|
|
+ Span::styled("Jobs: [", jobs_row_style.fg(Color::White)),
|
|
|
|
|
+ Span::styled(jobs_str, jobs_row_style.fg(Color::Cyan)),
|
|
|
|
|
+ Span::styled("] ", jobs_row_style.fg(Color::White)),
|
|
|
|
|
+ Span::styled("-/+", jobs_row_style.fg(Color::Green)),
|
|
|
|
|
+ ]),
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ let paragraph = Paragraph::new(text).block(block);
|
|
|
|
|
+ f.render_widget(paragraph, area);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn draw_search(f: &mut Frame, app: &mut App, area: Rect) {
|
|
|
|
|
+ let focused = app.focus == Focus::Search;
|
|
|
|
|
+ let border_style = if focused {
|
|
|
|
|
+ Style::default().fg(Color::Red)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::DarkGray)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let title = match &app.status_msg {
|
|
|
|
|
+ Some(msg) => format!(" Search - {} ", msg),
|
|
|
|
|
+ None => " Search (Enter to lookup) ".to_string(),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let block = Block::default()
|
|
|
|
|
+ .borders(Borders::ALL)
|
|
|
|
|
+ .border_style(border_style)
|
|
|
|
|
+ .title(title);
|
|
|
|
|
+
|
|
|
|
|
+ let inner = block.inner(area);
|
|
|
|
|
+ f.render_widget(block, area);
|
|
|
|
|
+
|
|
|
|
|
+ let search_button_width = SEARCH_BUTTON_LABEL.chars().count() as u16;
|
|
|
|
|
+ let clear_button_width = CLEAR_BUTTON_LABEL.chars().count() as u16;
|
|
|
|
|
+ let stop_button_width = STOP_BUTTON_LABEL.chars().count() as u16;
|
|
|
|
|
+ let chunks = if app.clear_on_search {
|
|
|
|
|
+ Layout::default()
|
|
|
|
|
+ .direction(Direction::Horizontal)
|
|
|
|
|
+ .constraints([
|
|
|
|
|
+ Constraint::Min(1),
|
|
|
|
|
+ Constraint::Length(search_button_width),
|
|
|
|
|
+ Constraint::Length(1),
|
|
|
|
|
+ Constraint::Length(stop_button_width),
|
|
|
|
|
+ ])
|
|
|
|
|
+ .split(inner)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Layout::default()
|
|
|
|
|
+ .direction(Direction::Horizontal)
|
|
|
|
|
+ .constraints([
|
|
|
|
|
+ Constraint::Min(1),
|
|
|
|
|
+ Constraint::Length(search_button_width),
|
|
|
|
|
+ Constraint::Length(1),
|
|
|
|
|
+ Constraint::Length(stop_button_width),
|
|
|
|
|
+ Constraint::Length(1),
|
|
|
|
|
+ Constraint::Length(clear_button_width),
|
|
|
|
|
+ ])
|
|
|
|
|
+ .split(inner)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ app.panel_rects.search_button = Some(chunks[1]);
|
|
|
|
|
+ if app.clear_on_search {
|
|
|
|
|
+ app.panel_rects.clear_button = None;
|
|
|
|
|
+ app.panel_rects.cancel_button = Some(chunks[3]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ app.panel_rects.cancel_button = Some(chunks[3]);
|
|
|
|
|
+ app.panel_rects.clear_button = Some(chunks[5]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let input_chunk = chunks[0];
|
|
|
|
|
+ let visible_input = fit_cell(&app.search_input, input_chunk.width as usize);
|
|
|
|
|
+ let input = Paragraph::new(visible_input).style(Style::default().fg(Color::White));
|
|
|
|
|
+ f.render_widget(input, input_chunk);
|
|
|
|
|
+
|
|
|
|
|
+ let search_enabled = !app.searching && !app.search_input.is_empty();
|
|
|
|
|
+ let cancel_enabled = app.searching;
|
|
|
|
|
+
|
|
|
|
|
+ let search_style = if search_enabled {
|
|
|
|
|
+ Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::DarkGray).bg(Color::Black)
|
|
|
|
|
+ };
|
|
|
|
|
+ let stop_style = if cancel_enabled {
|
|
|
|
|
+ Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Style::default().fg(Color::DarkGray).bg(Color::Black)
|
|
|
|
|
+ };
|
|
|
|
|
+ let clear_style = Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD);
|
|
|
|
|
+
|
|
|
|
|
+ f.render_widget(Paragraph::new(SEARCH_BUTTON_LABEL).style(search_style), chunks[1]);
|
|
|
|
|
+ if app.clear_on_search {
|
|
|
|
|
+ f.render_widget(Paragraph::new(STOP_BUTTON_LABEL).style(stop_style), chunks[3]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ f.render_widget(Paragraph::new(STOP_BUTTON_LABEL).style(stop_style), chunks[3]);
|
|
|
|
|
+ f.render_widget(Paragraph::new(CLEAR_BUTTON_LABEL).style(clear_style), chunks[5]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // show cursor in search bar when focused
|
|
|
|
|
+ if focused && app.export_popup.is_none() {
|
|
|
|
|
+ let max_cursor = input_chunk.width.saturating_sub(1) as usize;
|
|
|
|
|
+ let x = input_chunk.x + app.cursor_pos.min(max_cursor) as u16;
|
|
|
|
|
+ let y = input_chunk.y;
|
|
|
|
|
+ f.set_cursor_position((x, y));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn draw_dropdown(f: &mut Frame, app: &mut App, settings_area: Rect, selected: usize) {
|
|
|
|
|
+ let options = app.list_options();
|
|
|
|
|
+
|
|
|
|
|
+ // position dropdown below the TLD list line in settings
|
|
|
|
|
+ let dropdown_full = Rect {
|
|
|
|
|
+ x: settings_area.x + 1,
|
|
|
|
|
+ y: settings_area.y + 1,
|
|
|
|
|
+ width: settings_area.width.saturating_sub(2).min(DROPDOWN_MAX_WIDTH),
|
|
|
|
|
+ height: (options.len() as u16 + 2).min(DROPDOWN_MAX_HEIGHT),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ app.panel_rects.dropdown = Some(dropdown_full);
|
|
|
|
|
+
|
|
|
|
|
+ // clear the area behind the dropdown
|
|
|
|
|
+ f.render_widget(Clear, dropdown_full);
|
|
|
|
|
+
|
|
|
|
|
+ let items: Vec<ListItem> = options
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .map(|opt| {
|
|
|
|
|
+ ListItem::new(Line::from(Span::styled(
|
|
|
|
|
+ format!(" {} ", opt),
|
|
|
|
|
+ Style::default().fg(Color::White),
|
|
|
|
|
+ )))
|
|
|
|
|
+ })
|
|
|
|
|
+ .collect();
|
|
|
|
|
+
|
|
|
|
|
+ let block = Block::default()
|
|
|
|
|
+ .borders(Borders::ALL)
|
|
|
|
|
+ .border_style(Style::default().fg(Color::Red))
|
|
|
|
|
+ .title(" TLD List ");
|
|
|
|
|
+
|
|
|
|
|
+ f.render_widget(Clear, dropdown_full);
|
|
|
|
|
+ let list = List::new(items)
|
|
|
|
|
+ .block(block)
|
|
|
|
|
+ .highlight_style(Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD));
|
|
|
|
|
+ let mut state = ListState::default();
|
|
|
|
|
+ state.select(Some(selected));
|
|
|
|
|
+ f.render_stateful_widget(list, dropdown_full, &mut state);
|
|
|
|
|
+}
|