feat(slint): wire header search input with debounced live search

The header TextInput now submits on Enter (records history, opens the
results page) and live-searches on edit. Live search debounces 300 ms
via a Slint Timer and requires 2+ characters. A monotonic version
counter guards navigate_search so a stale load cannot overwrite newer
results.

cc
This commit is contained in:
vicrodh committed 2026-05-19 11:42:12 -06:00
1 parent 37ba47434a
commit 7073b6553f
3 files changed
+98 -5

No files matched your search

+72 -4
View File
@@ -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::<SearchState>().set_loading(false);
if search::is_current_version(version) {
search::apply_search(&w, data);
w.global::<SearchState>().set_loading(false);
}
});
}
Err(e) => {
log::error!("[qbz-slint] search load failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<SearchState>().set_loading(false);
let _ = weak.upgrade_in_event_loop(move |w| {
if search::is_current_version(version) {
w.global::<SearchState>().set_loading(false);
}
});
}
}
@@ -510,6 +523,61 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// 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::<SearchActions>().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::<SearchActions>().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.
{
+21
View File
@@ -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<u64> = 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`.
+5 -1
View File
@@ -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 == "";