diff options
Diffstat (limited to 'src/tui.rs')
| -rw-r--r-- | src/tui.rs | 2870 |
1 files changed, 2870 insertions, 0 deletions
diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..f6d9238 --- /dev/null +++ b/src/tui.rs @@ -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); +} |
