aboutsummaryrefslogtreecommitdiff
path: root/src/tui.rs
diff options
context:
space:
mode:
authorUMTS at Teleco <crt@teleco.ch>2026-03-08 07:30:34 +0100
committerUMTS at Teleco <crt@teleco.ch>2026-03-08 07:30:34 +0100
commit8623ef0ee74ff48a5ee24ee032f5b549f662f09d (patch)
tree7f11543d05cfe0e7bd5aaca31ff1d4c86a271fd0 /src/tui.rs
goofy ah
Diffstat (limited to 'src/tui.rs')
-rw-r--r--src/tui.rs2870
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);
+}