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:
3 files changed
+98
-5
No files matched your search
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 == "";
|
||||
|
||||
Reference in new issue
Block a user