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, BorderType, 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, default_list_name, get_tlds_or_default, list_names}; 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 mostly just relying // on copy pasting shit where i need it // // have fun and may you forgive me for this extremly large ahh file. // 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, status_success: bool, confirm_overwrite: bool, close_at: Option, } struct App { search_input: String, cursor_pos: usize, results: Vec<(usize, DomainResult)>, results_state: ListState, favorites: Vec, favorites_state: ListState, focus: Focus, show_unavailable: bool, clear_on_search: bool, tld_list_name: String, settings_selected: Option, 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, last_search_duration: Option, status_msg: Option, config_path: PathBuf, can_save: bool, cache_path: Option, force_refresh: bool, mouse_enabled: bool, top_tlds: Option>, only_top: Option>, imported_lists: Vec, 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>, stream_task: Option>, patch: crate::tlds::WhoisOverrides, noretry: Vec, should_quit: bool, show_help: bool, export_popup: Option, fav_check_rx: Option>, fav_check_task: Option>, checking_favorites: bool, backups_enabled: bool, backup_count: u32, no_color: bool, no_unicode: bool, } #[derive(Debug, Clone, Default)] struct PanelRects { topbar: Option, export_button: Option, help_button: Option, help_popup: Option, dropdown: Option, search: Option, search_button: Option, cancel_button: Option, clear_button: Option, export_popup: Option, export_mode_favorites: Option, export_mode_results: Option, export_path: Option, export_cancel: Option, export_save: Option, scratchpad: Option, results: Option, favorites: Option, fav_check_button: Option, settings: Option, } impl App { fn new( args: &Args, config: &Config, config_path: PathBuf, can_save: bool, cache_path: Option, force_refresh: bool, whois_overrides: crate::tlds::WhoisOverrides, noretry: Vec, ) -> 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, no_color: args.no_color, no_unicode: args.no_unicode, } } /// style with fg color, or plain style if no_color is on fn fg(&self, color: Color) -> Style { if self.no_color { Style::default() } else { Style::default().fg(color) } } /// style with fg color + bold fn fg_bold(&self, color: Color) -> Style { if self.no_color { Style::default().add_modifier(Modifier::BOLD) } else { Style::default().fg(color).add_modifier(Modifier::BOLD) } } /// resolve a color: returns Color::Reset when no_color is on /// use this when chaining .bg() or .fg() on existing styles fn c(&self, color: Color) -> Color { if self.no_color { Color::Reset } else { color } } // unicode symbol helpers fn sym_check(&self) -> &'static str { if self.no_unicode { "+" } else { "\u{2713}" } } fn sym_cross(&self) -> &'static str { if self.no_unicode { "x" } else { "\u{2717}" } } fn sym_sep(&self) -> &'static str { if self.no_unicode { "|" } else { "\u{2502}" } } fn sym_bar_filled(&self) -> &'static str { if self.no_unicode { "#" } else { "\u{2588}" } } fn sym_bar_empty(&self) -> &'static str { if self.no_unicode { "-" } else { "\u{2591}" } } /// preconfigured Block with ASCII or unicode borders fn block(&self) -> Block<'static> { let mut b = Block::default().borders(Borders::ALL); if self.no_unicode { b = b.border_type(BorderType::Plain); } b } 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 { let mut options: Vec = 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 { 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 { 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, force_refresh: bool, whois_overrides: crate::tlds::WhoisOverrides, noretry: Vec, ) -> 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>, 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, app, 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, app, 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, app: &App, area: Rect) { let block = app.block() .border_style(app.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), app.fg_bold(Color::White), )), Line::from(Span::styled( fit_cell_center("I AM BEING CRUSHED!", content_width), app.fg_bold(Color::White), )), Line::from(fit_cell_center("", content_width)), Line::from(Span::styled( fit_cell_center("Im claustrophobic! :'(", content_width), app.fg(Color::White), )), Line::from(Span::styled( fit_cell_center( &format!("Need {}x{} of space", MIN_UI_WIDTH, MIN_UI_HEIGHT), content_width, ), app.fg(Color::White), )), Line::from(Span::styled( fit_cell_center( &format!("Current: {}x{}", area.width, area.height), content_width, ), app.fg(Color::DarkGray), )), Line::from(fit_cell_center("", content_width)), Line::from(Span::styled( fit_cell_center("REFUSING TO WORK TILL YOU", content_width), app.fg_bold(Color::White), )), Line::from(Span::styled( fit_cell_center("GIVE ME BACK MY SPACE! >:(", content_width), app.fg_bold(Color::White), )), ]; f.render_widget(Paragraph::new(text), inner); } fn draw_topbar(f: &mut Frame, app: &App, 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, app.fg_bold(Color::Red).bg(app.c(Color::Gray)), ), Span::styled( format!(" {}", title), app.fg_bold(Color::White).bg(app.c(Color::Red)), ), Span::styled( " ".repeat(gap), Style::default().bg(app.c(Color::Red)).add_modifier(Modifier::BOLD), ), Span::styled( EXPORT_BUTTON_LABEL, app.fg_bold(Color::LightGreen).bg(app.c(Color::Red)), ), Span::styled( " ", Style::default().bg(app.c(Color::Red)).add_modifier(Modifier::BOLD), ), Span::styled( HELP_BUTTON_LABEL, app.fg_bold(Color::LightGreen).bg(app.c(Color::Red)), ), ])) .style( app.fg_bold(Color::White).bg(app.c(Color::Red)), ); 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(" ", app.fg(Color::White))), Line::from(Span::styled("Global :", app.fg(Color::White))), Line::from(Span::styled( "F1 or Help button Toggle this help", app.fg(Color::White), )), Line::from(Span::styled( "F2 or Export button Open export popup", app.fg(Color::White), )), Line::from(Span::styled( "Ctrl+C Quit the app", app.fg(Color::White), )), Line::from(Span::styled( "s Stop/cancel running search", app.fg(Color::White), )), Line::from(Span::styled( "Esc Clear selection or close help", app.fg(Color::White), )), Line::from(Span::styled( "Tab or Shift+Tab Move between panels", app.fg(Color::White), )), Line::from(Span::styled( "Up and Down arrows Navigate results", app.fg(Color::White), )), Line::from(Span::styled(" ", app.fg(Color::White))), Line::from(Span::styled( "Mouse Click Elements duh", app.fg(Color::White), )), Line::from(Span::styled( "Scrolling Scroll through elements (yea)", app.fg(Color::White), )), Line::from(Span::styled(" ", app.fg(Color::White))), Line::from(Span::styled( "In Results :", app.fg(Color::White), )), Line::from(Span::styled( "Enter Add highlighted result to Favorites", app.fg(Color::White), )), Line::from(Span::styled( "In Favorites :", app.fg(Color::White), )), Line::from(Span::styled( "Backspace or Delete Remove focused favorite", app.fg(Color::White), )), ]; let block = app.block() .border_style(app.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 = app.block() .border_style(app.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 white_style = app.fg(Color::White); let mode_style = |mode: ExportMode| { let mut style = white_style; 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(app.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 = app.block() .border_style(if popup_state.selected_row == 1 { app.fg(Color::Red) } else { app.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(app.fg(Color::White)), path_inner, ); let status_style = if popup_state.status_success { app.fg_bold(Color::Green) } else if popup_state.confirm_overwrite { app.fg_bold(Color::Yellow) } else if popup_state.status.is_some() { app.fg(Color::Red) } else { app.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 { app.fg_bold(Color::Green).bg(app.c(Color::DarkGray)) } else { app.fg_bold(Color::Green) }, ), Span::raw(button_gap), Span::styled( save_label, if popup_state.selected_row == 3 { app.fg_bold(Color::Green).bg(app.c(Color::DarkGray)) } else { app.fg_bold(Color::Green) }, ), ]); 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 { app.fg(Color::Red) } else { app.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 = app.block() .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 = app.sym_bar_filled().repeat(filled as usize) + &app.sym_bar_empty().repeat((chunks[0].width.saturating_sub(filled)) as usize); let bar_text = format!(" {}% ", pct); let gauge_line = Line::from(vec![ Span::styled(bar, app.fg(Color::Red)), Span::styled(bar_text, app.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; let sep = format!(" {} ", app.sym_sep()); // 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(app.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)), app.fg_bold(Color::Gray), ), Span::styled(sep.clone(), app.fg(Color::DarkGray)), Span::styled( fit_cell("Status", status_w), app.fg_bold(Color::Gray), ), ]; if show_note_column { header_spans.push(Span::styled(sep.clone(), app.fg(Color::DarkGray))); header_spans.push(Span::styled( fit_cell("Details", note_w), app.fg_bold(Color::Gray), )); } header_spans.push(Span::styled(sep.clone(), app.fg(Color::DarkGray))); header_spans.push(Span::styled( format!(" {} ", app.sym_check()), app.fg_bold(Color::Gray), )); f.render_widget(Paragraph::new(Line::from(header_spans)), header_area); } let items: Vec = 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 => app.fg(Color::Green), DomainStatus::Registered { .. } => app.fg(Color::Red), DomainStatus::Error { kind, .. } => match kind { ErrorKind::InvalidTld => app.fg(Color::Yellow), _ => app.fg(Color::Blue), }, }; let domain_style = match status { DomainStatus::Available => app.fg(Color::Green), DomainStatus::Registered { .. } => app.fg(Color::Red), DomainStatus::Error { kind, .. } => match kind { ErrorKind::InvalidTld => app.fg(Color::Yellow), _ => app.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(sep.clone(), apply_bg(app.fg(Color::Gray))), Span::styled(fit_cell(status_str, status_w), apply_bg(status_style)), ]; if show_note_column { spans.push(Span::styled( sep.clone(), apply_bg(app.fg(Color::Gray)), )); spans.push(Span::styled( fit_cell(note, note_w), apply_bg(app.fg(Color::White)), )); } spans.push(Span::styled( sep.clone(), apply_bg(app.fg(Color::Gray)), )); spans.push(match status { DomainStatus::Available => { Span::styled(format!(" {} ", app.sym_check()), apply_bg(app.fg(Color::Green))) } DomainStatus::Registered { .. } => { Span::styled(format!(" {} ", app.sym_cross()), apply_bg(app.fg(Color::Red))) } DomainStatus::Error { kind, .. } => match kind { ErrorKind::InvalidTld => { Span::styled(" ? ", apply_bg(app.fg(Color::Yellow))) } _ => Span::styled(" ! ", apply_bg(app.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!("{: 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 { 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 = 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 { app.fg(Color::Red) } else { app.fg(Color::DarkGray) }; let block = app.block() .border_style(border_style) .title(" Scratchpad "); let inner = block.inner(area); let wrapped_lines = wrap_text_lines(&app.scratchpad, inner.width); let text: Vec = 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(app.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 { app.fg(Color::Red) } else { app.fg(Color::DarkGray) }; let title = if app.checking_favorites { " Favorites (checking...) " } else { " Favorites " }; let block = app.block() .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 = 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(), app.fg(status_color), )]; if fav.changed { spans.push(Span::styled(" !", app.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 { app.fg(Color::DarkGray) } else { app.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 { app.fg(Color::Red) } else { app.fg(Color::DarkGray) }; let block = app.block() .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 { app.fg(Color::Green) } else { app.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 { app.fg(Color::White) } }; let tld_row_style = if selected == Some(0) { Style::default() .bg(app.c(Color::DarkGray)) .add_modifier(Modifier::BOLD) } else { Style::default() }; let jobs_row_style = if selected == Some(4) { Style::default() .bg(app.c(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(app.c(Color::White))), Span::styled(app.tld_list_name.as_str(), tld_row_style.fg(app.c(Color::Cyan))), Span::styled("] ", tld_row_style.fg(app.c(Color::White))), Span::styled("V", tld_row_style.fg(app.c(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(app.c(Color::White))), Span::styled(jobs_str, jobs_row_style.fg(app.c(Color::Cyan))), Span::styled("] ", jobs_row_style.fg(app.c(Color::White))), Span::styled("-/+", jobs_row_style.fg(app.c(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 { app.fg(Color::Red) } else { app.fg(Color::DarkGray) }; let title = match &app.status_msg { Some(msg) => format!(" Search - {} ", msg), None => " Search (Enter to lookup) ".to_string(), }; let block = app.block() .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(app.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 { app.fg_bold(Color::Black).bg(app.c(Color::Green)) } else { app.fg(Color::DarkGray).bg(app.c(Color::Black)) }; let stop_style = if cancel_enabled { app.fg_bold(Color::Black).bg(app.c(Color::Yellow)) } else { app.fg(Color::DarkGray).bg(app.c(Color::Black)) }; let clear_style = app.fg_bold(Color::White).bg(app.c(Color::Red)); 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 = options .iter() .map(|opt| { ListItem::new(Line::from(Span::styled( format!(" {} ", opt), app.fg(Color::White), ))) }) .collect(); let block = app.block() .border_style(app.fg(Color::Red)) .title(" TLD List "); f.render_widget(Clear, dropdown_full); let list = List::new(items).block(block).highlight_style( app.fg_bold(Color::White).bg(app.c(Color::Red)), ); let mut state = ListState::default(); state.select(Some(selected)); f.render_stateful_widget(list, dropdown_full, &mut state); }