feat(slint): load-more pagination for per-type search tabs
The Load more button now fetches the next 20-item page for the active category via the per-type QbzCore search methods and appends onto the existing VecModel, so already-loaded rows and their artwork are untouched. category_for_tab maps the tab index to a category and is unit-tested. cc
This commit is contained in:
2 files changed
+187
-1
No files matched your search
@@ -31,6 +31,8 @@ mod ui_prefs;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use slint::Model;
|
||||
|
||||
use adapter::SlintAdapter;
|
||||
use commands::AppCommand;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
@@ -589,6 +591,42 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Load more results for the active per-type tab. The offset is the
|
||||
// count already loaded into that category's list.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.global::<SearchActions>().on_load_more(move |tab| {
|
||||
let Some(w) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(category) = search::category_for_tab(tab) else {
|
||||
return;
|
||||
};
|
||||
let st = w.global::<SearchState>();
|
||||
let query = st.get_query().to_string();
|
||||
let offset = match category {
|
||||
search::SearchCategory::Albums => st.get_albums().row_count(),
|
||||
search::SearchCategory::Tracks => st.get_tracks().row_count(),
|
||||
search::SearchCategory::Artists => st.get_artists().row_count(),
|
||||
search::SearchCategory::Playlists => st.get_playlists().row_count(),
|
||||
} as u32;
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
handle.spawn(async move {
|
||||
match search::load_more(&runtime, &query, category, offset).await {
|
||||
Ok(more) => {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
search::append_results(&w, more);
|
||||
});
|
||||
}
|
||||
Err(e) => log::error!("[qbz-slint] search load-more failed: {e}"),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// History navigation — back / forward / settings, all recorded by the
|
||||
// nav module so the [<] [>] pair and the mouse buttons stay in sync.
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::Arc;
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::FrontendAdapter;
|
||||
use qbz_models::{Album, Artist, MostPopularItem, Playlist, SearchAllResults, Track};
|
||||
use slint::{ComponentHandle, ModelRc, VecModel};
|
||||
use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
||||
|
||||
use crate::{AlbumCardItem, AppWindow, SearchPlaylistItem, SearchState, SearchTrackItem, SlimItem};
|
||||
|
||||
@@ -377,10 +377,158 @@ pub fn reset_search(window: &AppWindow) {
|
||||
state.set_loading(true);
|
||||
}
|
||||
|
||||
// ==================== Load-more (pagination) ====================
|
||||
|
||||
/// Which category a load-more request targets.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum SearchCategory {
|
||||
Albums,
|
||||
Tracks,
|
||||
Artists,
|
||||
Playlists,
|
||||
}
|
||||
|
||||
/// Map a results-tab index to the category whose list it paginates.
|
||||
/// Tab 0 (All) has no single category.
|
||||
pub fn category_for_tab(tab: i32) -> Option<SearchCategory> {
|
||||
match tab {
|
||||
1 => Some(SearchCategory::Albums),
|
||||
2 => Some(SearchCategory::Tracks),
|
||||
3 => Some(SearchCategory::Artists),
|
||||
4 => Some(SearchCategory::Playlists),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A page of additional rows fetched by load-more, ready to append.
|
||||
pub enum MoreRows {
|
||||
Albums(Vec<AlbumRow>),
|
||||
Tracks(Vec<TrackRow>),
|
||||
Artists(Vec<ArtistRow>),
|
||||
Playlists(Vec<PlaylistRow>),
|
||||
}
|
||||
|
||||
/// Load-more page size (matches the Tauri search page size).
|
||||
const PAGE_SIZE: u32 = 20;
|
||||
|
||||
/// Fetch the next page for one category, starting at `offset`.
|
||||
pub async fn load_more<A>(
|
||||
runtime: &Arc<AppRuntime<A>>,
|
||||
query: &str,
|
||||
category: SearchCategory,
|
||||
offset: u32,
|
||||
) -> Result<MoreRows, String>
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
let core = runtime.core();
|
||||
match category {
|
||||
SearchCategory::Albums => {
|
||||
let page = core
|
||||
.search_albums(query, PAGE_SIZE, offset, None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(MoreRows::Albums(
|
||||
page.items.into_iter().map(map_album).collect(),
|
||||
))
|
||||
}
|
||||
SearchCategory::Tracks => {
|
||||
let page = core
|
||||
.search_tracks(query, PAGE_SIZE, offset, None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(MoreRows::Tracks(
|
||||
page.items.into_iter().map(map_track).collect(),
|
||||
))
|
||||
}
|
||||
SearchCategory::Artists => {
|
||||
let page = core
|
||||
.search_artists(query, PAGE_SIZE, offset, None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(MoreRows::Artists(
|
||||
page.items.iter().map(map_artist).collect(),
|
||||
))
|
||||
}
|
||||
SearchCategory::Playlists => {
|
||||
let page = core
|
||||
.search_playlists(query, PAGE_SIZE, offset)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(MoreRows::Playlists(
|
||||
page.items.into_iter().map(map_playlist).collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append fetched rows to the matching `SearchState` list. Pushes onto the
|
||||
/// existing `VecModel` so already-loaded rows (and any resolved artwork)
|
||||
/// are untouched. Runs on the Slint event loop.
|
||||
pub fn append_results(window: &AppWindow, more: MoreRows) {
|
||||
let state = window.global::<SearchState>();
|
||||
match more {
|
||||
MoreRows::Albums(rows) => {
|
||||
if let Some(vm) = state
|
||||
.get_albums()
|
||||
.as_any()
|
||||
.downcast_ref::<VecModel<AlbumCardItem>>()
|
||||
{
|
||||
for row in rows {
|
||||
vm.push(album_item(row));
|
||||
}
|
||||
}
|
||||
}
|
||||
MoreRows::Tracks(rows) => {
|
||||
if let Some(vm) = state
|
||||
.get_tracks()
|
||||
.as_any()
|
||||
.downcast_ref::<VecModel<SearchTrackItem>>()
|
||||
{
|
||||
for row in rows {
|
||||
vm.push(track_item(row));
|
||||
}
|
||||
}
|
||||
}
|
||||
MoreRows::Artists(rows) => {
|
||||
if let Some(vm) = state
|
||||
.get_artists()
|
||||
.as_any()
|
||||
.downcast_ref::<VecModel<SlimItem>>()
|
||||
{
|
||||
for row in rows {
|
||||
vm.push(artist_item(row));
|
||||
}
|
||||
}
|
||||
}
|
||||
MoreRows::Playlists(rows) => {
|
||||
if let Some(vm) = state
|
||||
.get_playlists()
|
||||
.as_any()
|
||||
.downcast_ref::<VecModel<SearchPlaylistItem>>()
|
||||
{
|
||||
for row in rows {
|
||||
vm.push(playlist_item(row));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn category_for_tab_maps_per_type_tabs() {
|
||||
assert_eq!(category_for_tab(0), None);
|
||||
assert_eq!(category_for_tab(1), Some(SearchCategory::Albums));
|
||||
assert_eq!(category_for_tab(2), Some(SearchCategory::Tracks));
|
||||
assert_eq!(category_for_tab(3), Some(SearchCategory::Artists));
|
||||
assert_eq!(category_for_tab(4), Some(SearchCategory::Playlists));
|
||||
assert_eq!(category_for_tab(9), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mmss_pads_seconds() {
|
||||
assert_eq!(mmss(5), "0:05");
|
||||
|
||||
Reference in new issue
Block a user