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
|
/// Run a search and show the results view. Shared by the search-submit
|
||||||
/// callback, the live-search debounce, and history back/forward.
|
/// callback, the live-search debounce, and history back/forward.
|
||||||
fn navigate_search(
|
fn navigate_search(
|
||||||
@@ -258,6 +264,9 @@ fn navigate_search(
|
|||||||
_image_cache: artwork::ImageCache,
|
_image_cache: artwork::ImageCache,
|
||||||
query: String,
|
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 {
|
handle.spawn(async move {
|
||||||
let _ = weak.upgrade_in_event_loop(|w| {
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
search::reset_search(&w);
|
search::reset_search(&w);
|
||||||
@@ -266,14 +275,18 @@ fn navigate_search(
|
|||||||
match search::load_search(&runtime, &query).await {
|
match search::load_search(&runtime, &query).await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
search::apply_search(&w, data);
|
if search::is_current_version(version) {
|
||||||
w.global::<SearchState>().set_loading(false);
|
search::apply_search(&w, data);
|
||||||
|
w.global::<SearchState>().set_loading(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("[qbz-slint] search load failed: {e}");
|
log::error!("[qbz-slint] search load failed: {e}");
|
||||||
let _ = weak.upgrade_in_event_loop(|w| {
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
w.global::<SearchState>().set_loading(false);
|
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
|
// History navigation — back / forward / settings, all recorded by the
|
||||||
// nav module so the [<] [>] pair and the mouse buttons stay in sync.
|
// 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};
|
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 ====================
|
// ==================== Plain (Send) row types ====================
|
||||||
|
|
||||||
/// An album result row, before it becomes a Slint `AlbumCardItem`.
|
/// 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 { Typography } from "../foundation/typography.slint";
|
||||||
import { Layout } from "../foundation/layout.slint";
|
import { Layout } from "../foundation/layout.slint";
|
||||||
import { Radius } from "../foundation/radius.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";
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
|
||||||
// Sidebar collapse toggle — bare bright icon, no background. Sits at
|
// Sidebar collapse toggle — bare bright icon, no background. Sits at
|
||||||
@@ -341,6 +341,10 @@ export component HeaderBar inherits Rectangle {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
single-line: true;
|
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 {
|
Text {
|
||||||
visible: search-input.text == "";
|
visible: search-input.text == "";
|
||||||
|
|||||||
Reference in new issue
Block a user