diff --git a/src/ui/app.rs b/src/ui/app.rs index f0bea7a..bceeba3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -44,6 +44,23 @@ pub struct ToastMessage { pub expires_at: Instant, } +const SLOW_SEARCH_THRESHOLD: Duration = Duration::from_millis(100); +const FAST_SEARCH_THRESHOLD: Duration = Duration::from_millis(50); +const DEBOUNCE_STEP_MS: u64 = 50; +const MAX_EFFECTIVE_DEBOUNCE_MS: u64 = 1000; + +fn adjusted_debounce_ms(current: u64, floor: u64, duration: Duration) -> u64 { + let current = current.max(floor); + + if duration > SLOW_SEARCH_THRESHOLD { + current.saturating_add(DEBOUNCE_STEP_MS).min(MAX_EFFECTIVE_DEBOUNCE_MS).max(floor) + } else if duration < FAST_SEARCH_THRESHOLD { + current.saturating_sub(DEBOUNCE_STEP_MS).max(floor) + } else { + current + } +} + pub struct App { pub input: String, pub character_index: usize, @@ -80,6 +97,9 @@ pub struct App { last_input_time: Instant, pending_search: bool, last_search_query: String, + active_search_started_at: Option<(String, Instant)>, + pub last_search_duration: Option, + pub effective_debounce_ms: u64, } impl App { @@ -107,6 +127,7 @@ impl App { let manager = Arc::new(managers::get_system_manager(&config)); let available_managers = managers::get_available_managers(); + let effective_debounce_ms = config.settings.search_debounce_ms; let current_tab = match config.settings.default_tab.as_str() { "Installed" => Tab::Installed, @@ -149,6 +170,9 @@ impl App { last_input_time: Instant::now(), pending_search: false, last_search_query: String::new(), + active_search_started_at: None, + last_search_duration: None, + effective_debounce_ms, }; if app.current_tab != Tab::Search { @@ -240,10 +264,10 @@ impl App { /// Checks if the debounce period has passed and executes the search if necessary. fn check_and_execute_search(&mut self) { - let debounce_ms = self.config.settings.search_debounce_ms; + self.sync_debounce_floor(); if self.pending_search - && self.last_input_time.elapsed() >= Duration::from_millis(debounce_ms) + && self.last_input_time.elapsed() >= Duration::from_millis(self.effective_debounce_ms) { let query = self.input.trim().to_string(); @@ -251,6 +275,7 @@ impl App { self.last_search_query = query.clone(); self.pending_search = false; self.loading = true; + self.active_search_started_at = Some((query.clone(), Instant::now())); let tx = self.result_tx.clone(); let manager = self.manager.clone(); @@ -262,12 +287,41 @@ impl App { }); } else if query.is_empty() { self.pending_search = false; + self.active_search_started_at = None; self.packages.clear(); self.messages.clear(); self.loading = false; } } } + + fn sync_debounce_floor(&mut self) { + let floor = self.config.settings.search_debounce_ms; + if self.effective_debounce_ms < floor { + self.effective_debounce_ms = floor; + } + } + + fn record_search_duration(&mut self, query: &str) { + let Some((active_query, started_at)) = self.active_search_started_at.take() else { + return; + }; + + if active_query != query { + self.active_search_started_at = Some((active_query, started_at)); + return; + } + + let duration = started_at.elapsed(); + self.last_search_duration = Some(duration); + self.adjust_effective_debounce(duration); + } + + fn adjust_effective_debounce(&mut self, duration: Duration) { + let floor = self.config.settings.search_debounce_ms; + self.effective_debounce_ms = + adjusted_debounce_ms(self.effective_debounce_ms, floor, duration); + } fn run_command( &self, terminal: &mut DefaultTerminal, @@ -348,6 +402,7 @@ impl App { self.messages.clear(); self.checked.clear(); self.last_search_query.clear(); + self.active_search_started_at = None; self.selected = 0; self.list_state.select(None); self.details_state = DetailsState::Empty; @@ -576,6 +631,10 @@ impl App { }; if is_current_tab_result { + if matches!(self.current_tab, Tab::Search) { + self.record_search_duration(&q); + } + self.packages = pkgs; self.checked = self @@ -1106,6 +1165,7 @@ impl App { 3 => { if let Ok(n) = val.parse() { self.config.settings.search_debounce_ms = n; + self.sync_debounce_floor(); saved = true; } } @@ -1156,3 +1216,28 @@ impl App { } } } + +#[cfg(test)] +mod tests { + use super::adjusted_debounce_ms; + use std::time::Duration; + + #[test] + fn slow_searches_raise_debounce_until_cap() { + assert_eq!(adjusted_debounce_ms(200, 200, Duration::from_millis(101)), 250); + assert_eq!(adjusted_debounce_ms(990, 200, Duration::from_millis(150)), 1000); + } + + #[test] + fn fast_searches_decay_toward_configured_floor() { + assert_eq!(adjusted_debounce_ms(300, 200, Duration::from_millis(20)), 250); + assert_eq!(adjusted_debounce_ms(220, 200, Duration::from_millis(20)), 200); + assert_eq!(adjusted_debounce_ms(150, 200, Duration::from_millis(20)), 200); + } + + #[test] + fn mid_range_searches_leave_debounce_unchanged() { + assert_eq!(adjusted_debounce_ms(350, 200, Duration::from_millis(75)), 350); + assert_eq!(adjusted_debounce_ms(150, 200, Duration::from_millis(75)), 200); + } +}