diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index a431b51cc..be6dca3f1 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -249,6 +249,12 @@ fn navigate_artist( }); } +thread_local! { + /// Debounce timer for the header live search — restarted on every + /// keystroke, fires the search 300 ms after typing stops. + static SEARCH_DEBOUNCE: slint::Timer = slint::Timer::default(); +} + /// Run a search and show the results view. Shared by the search-submit /// callback, the live-search debounce, and history back/forward. fn navigate_search( @@ -258,6 +264,9 @@ fn navigate_search( _image_cache: artwork::ImageCache, query: String, ) { + // Capture a version so a slow, stale load cannot overwrite a newer + // search's results (the user kept typing). + let version = search::next_search_version(); handle.spawn(async move { let _ = weak.upgrade_in_event_loop(|w| { search::reset_search(&w); @@ -266,14 +275,18 @@ fn navigate_search( match search::load_search(&runtime, &query).await { Ok(data) => { let _ = weak.upgrade_in_event_loop(move |w| { - search::apply_search(&w, data); - w.global::().set_loading(false); + if search::is_current_version(version) { + search::apply_search(&w, data); + w.global::().set_loading(false); + } }); } Err(e) => { log::error!("[qbz-slint] search load failed: {e}"); - let _ = weak.upgrade_in_event_loop(|w| { - w.global::().set_loading(false); + let _ = weak.upgrade_in_event_loop(move |w| { + if search::is_current_version(version) { + w.global::().set_loading(false); + } }); } } @@ -510,6 +523,61 @@ fn main() -> Result<(), Box> { }); } + // Submit search (Enter): record history and show the results page. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window.global::().on_submit(move |query| { + let q = query.trim().to_string(); + if q.len() < 2 { + return; + } + SEARCH_DEBOUNCE.with(|t| t.stop()); + nav::record(nav::NavEntry::Search(q.clone())); + navigate_search(runtime.clone(), weak.clone(), &handle, image_cache.clone(), q); + if let Some(w) = weak.upgrade() { + update_nav_flags(&w); + } + }); + } + + // Live search: debounce 300 ms, minimum 2 characters. Does not record + // history (per-keystroke entries would pollute the back stack). + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window.global::().on_live(move |query| { + let q = query.trim().to_string(); + if q.len() < 2 { + SEARCH_DEBOUNCE.with(|t| t.stop()); + return; + } + let runtime = runtime.clone(); + let weak = weak.clone(); + let handle = handle.clone(); + let image_cache = image_cache.clone(); + SEARCH_DEBOUNCE.with(|t| { + t.start( + slint::TimerMode::SingleShot, + std::time::Duration::from_millis(300), + move || { + navigate_search( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + q.clone(), + ); + }, + ); + }); + }); + } + // History navigation — back / forward / settings, all recorded by the // nav module so the [<] [>] pair and the mouse buttons stay in sync. { diff --git a/crates/qbz-slint/src/search.rs b/crates/qbz-slint/src/search.rs index 1c9ca5292..a2ba8ea6c 100644 --- a/crates/qbz-slint/src/search.rs +++ b/crates/qbz-slint/src/search.rs @@ -14,6 +14,27 @@ use slint::{ComponentHandle, ModelRc, VecModel}; use crate::{AlbumCardItem, AppWindow, SearchPlaylistItem, SearchState, SearchTrackItem, SlimItem}; +thread_local! { + /// Monotonic search-attempt counter. Each `navigate_search` captures the + /// current value; a stale async load whose version is no longer current + /// must not overwrite a newer search's results. UI thread only. + static SEARCH_VERSION: std::cell::Cell = std::cell::Cell::new(0); +} + +/// Bump the search version and return the new value. +pub fn next_search_version() -> u64 { + SEARCH_VERSION.with(|c| { + let v = c.get() + 1; + c.set(v); + v + }) +} + +/// Whether `version` is still the most recent search attempt. +pub fn is_current_version(version: u64) -> bool { + SEARCH_VERSION.with(|c| c.get() == version) +} + // ==================== Plain (Send) row types ==================== /// An album result row, before it becomes a Slint `AlbumCardItem`. diff --git a/crates/qbz-slint/ui/shell/HeaderBar.slint b/crates/qbz-slint/ui/shell/HeaderBar.slint index 1901a7460..fd927a63b 100644 --- a/crates/qbz-slint/ui/shell/HeaderBar.slint +++ b/crates/qbz-slint/ui/shell/HeaderBar.slint @@ -13,7 +13,7 @@ import { Spacing } from "../foundation/spacing.slint"; import { Typography } from "../foundation/typography.slint"; import { Layout } from "../foundation/layout.slint"; import { Radius } from "../foundation/radius.slint"; -import { SessionState, NavState, ContentView, ShellState, HeaderMenuState, NavMenuEntry } from "../state.slint"; +import { SessionState, NavState, ContentView, ShellState, HeaderMenuState, NavMenuEntry, SearchActions } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; // Sidebar collapse toggle — bare bright icon, no background. Sits at @@ -341,6 +341,10 @@ export component HeaderBar inherits Rectangle { font-size: 13px; vertical-alignment: center; single-line: true; + // Enter submits to the full results page; every edit + // feeds the debounced live search (Rust side). + accepted => { SearchActions.submit(self.text); } + edited => { SearchActions.live(self.text); } } Text { visible: search-input.text == "";