feat(slint): searchType filter dropdown for per-type tabs

The Albums / Tracks / Artists tabs get a QbzSelect filter (No filter /
Main artist / Performer / Composer / Label / Release name). Changing it
re-queries that category from offset 0 with the search_type and replaces
the list; load-more carries the active filter forward. The filter
resets on every new search. search_type_for_filter is unit-tested.

cc
This commit is contained in:
vicrodh committed 2026-05-19 12:35:20 -06:00
1 parent 6a584803f1
commit fa5f22e609
4 files changed
+116 -8

No files matched your search

+38 -1
View File
@@ -609,6 +609,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
};
let st = w.global::<SearchState>();
let query = st.get_query().to_string();
let filter = search::search_type_for_filter(st.get_filter_index());
let offset = match category {
search::SearchCategory::Albums => st.get_albums().row_count(),
search::SearchCategory::Tracks => st.get_tracks().row_count(),
@@ -619,7 +620,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let weak = weak.clone();
let image_cache = image_cache.clone();
handle.spawn(async move {
match search::load_more(&runtime, &query, category, offset).await {
match search::load_more(&runtime, &query, category, filter, offset).await {
Ok(more) => {
let jobs = search::artwork_jobs_for_more(&more, offset as usize);
let _ = weak.upgrade_in_event_loop(move |w| {
@@ -633,6 +634,42 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Change the searchType filter on a per-type tab: re-query that
// category from offset 0 and replace its list.
{
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_filter_changed(move |index| {
let Some(w) = weak.upgrade() else {
return;
};
let st = w.global::<SearchState>();
st.set_filter_index(index);
let Some(category) = search::category_for_tab(st.get_tab()) else {
return;
};
let query = st.get_query().to_string();
let search_type = search::search_type_for_filter(index);
let runtime = runtime.clone();
let weak = weak.clone();
let image_cache = image_cache.clone();
handle.spawn(async move {
match search::load_more(&runtime, &query, category, search_type, 0).await {
Ok(more) => {
let jobs = search::artwork_jobs_for_more(&more, 0);
let _ = weak.upgrade_in_event_loop(move |w| {
search::replace_category(&w, more);
});
artwork::spawn_loads(jobs, weak.clone(), image_cache);
}
Err(e) => log::error!("[qbz-slint] search filter failed: {e}"),
}
});
});
}
// History navigation — back / forward / settings, all recorded by the
// nav module so the [<] [>] pair and the mouse buttons stay in sync.
{
+52 -3
View File
@@ -375,6 +375,7 @@ pub fn reset_search(window: &AppWindow) {
state.set_artists_total(0);
state.set_playlists_total(0);
state.set_most_popular_kind("".into());
state.set_filter_index(0);
state.set_loading(true);
}
@@ -401,6 +402,19 @@ pub fn category_for_tab(tab: i32) -> Option<SearchCategory> {
}
}
/// Map a filter-dropdown index to the Qobuz `search_type` value.
/// Index 0 (No filter) maps to `None`.
pub fn search_type_for_filter(index: i32) -> Option<String> {
match index {
1 => Some("MainArtist".into()),
2 => Some("Performer".into()),
3 => Some("Composer".into()),
4 => Some("Label".into()),
5 => Some("ReleaseName".into()),
_ => None,
}
}
/// A page of additional rows fetched by load-more, ready to append.
pub enum MoreRows {
Albums(Vec<AlbumRow>),
@@ -417,16 +431,18 @@ pub async fn load_more<A>(
runtime: &Arc<AppRuntime<A>>,
query: &str,
category: SearchCategory,
search_type: Option<String>,
offset: u32,
) -> Result<MoreRows, String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let core = runtime.core();
let search_type = search_type.as_deref();
match category {
SearchCategory::Albums => {
let page = core
.search_albums(query, PAGE_SIZE, offset, None)
.search_albums(query, PAGE_SIZE, offset, search_type)
.await
.map_err(|e| e.to_string())?;
Ok(MoreRows::Albums(
@@ -435,7 +451,7 @@ where
}
SearchCategory::Tracks => {
let page = core
.search_tracks(query, PAGE_SIZE, offset, None)
.search_tracks(query, PAGE_SIZE, offset, search_type)
.await
.map_err(|e| e.to_string())?;
Ok(MoreRows::Tracks(
@@ -444,7 +460,7 @@ where
}
SearchCategory::Artists => {
let page = core
.search_artists(query, PAGE_SIZE, offset, None)
.search_artists(query, PAGE_SIZE, offset, search_type)
.await
.map_err(|e| e.to_string())?;
Ok(MoreRows::Artists(
@@ -516,6 +532,30 @@ pub fn append_results(window: &AppWindow, more: MoreRows) {
}
}
/// Replace one category's `SearchState` list wholesale — used when the
/// searchType filter changes and the category is re-queried from offset 0.
pub fn replace_category(window: &AppWindow, more: MoreRows) {
let state = window.global::<SearchState>();
match more {
MoreRows::Albums(rows) => {
let items: Vec<AlbumCardItem> = rows.into_iter().map(album_item).collect();
state.set_albums(ModelRc::new(VecModel::from(items)));
}
MoreRows::Tracks(rows) => {
let items: Vec<SearchTrackItem> = rows.into_iter().map(track_item).collect();
state.set_tracks(ModelRc::new(VecModel::from(items)));
}
MoreRows::Artists(rows) => {
let items: Vec<SlimItem> = rows.into_iter().map(artist_item).collect();
state.set_artists(ModelRc::new(VecModel::from(items)));
}
MoreRows::Playlists(rows) => {
let items: Vec<SearchPlaylistItem> = rows.into_iter().map(playlist_item).collect();
state.set_playlists(ModelRc::new(VecModel::from(items)));
}
}
}
// ==================== Artwork jobs ====================
/// Build artwork download jobs for a freshly applied `SearchData` — one
@@ -620,6 +660,15 @@ mod tests {
assert_eq!(category_for_tab(9), None);
}
#[test]
fn search_type_for_filter_maps_dropdown_index() {
assert_eq!(search_type_for_filter(0), None);
assert_eq!(search_type_for_filter(1), Some("MainArtist".to_string()));
assert_eq!(search_type_for_filter(3), Some("Composer".to_string()));
assert_eq!(search_type_for_filter(5), Some("ReleaseName".to_string()));
assert_eq!(search_type_for_filter(99), None);
}
#[test]
fn mmss_pads_seconds() {
assert_eq!(mmss(5), "0:05");
@@ -15,6 +15,7 @@ import { SearchTrackRow } from "SearchTrackRow.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
// One tab in the results tab strip.
component SearchTab inherits Rectangle {
@@ -375,6 +376,26 @@ export component SearchResultsView inherits Rectangle {
}
}
// searchType filter — shown on the Albums / Tracks / Artists tabs.
if SearchState.tab >= 1 && SearchState.tab <= 3: HorizontalLayout {
alignment: start;
QbzSelect {
menu-width: 180px;
options: [
@tr("No filter"),
@tr("Main artist"),
@tr("Performer"),
@tr("Composer"),
@tr("Label"),
@tr("Release name"),
];
current-index: SearchState.filter-index;
selected(i) => {
SearchActions.filter-changed(i);
}
}
}
// Scrollable results region.
Flickable {
vertical-stretch: 1;
+5 -4
View File
@@ -372,8 +372,9 @@ export global SearchState {
in property <AlbumCardItem> most-popular-album;
in property <SlimItem> most-popular-artist;
in property <SearchTrackItem> most-popular-track;
// Active searchType filter: "" | "MainArtist" | "Performer" | ...
in property <string> filter-type;
// Active searchType filter, as a dropdown index (0 = no filter).
// The controller owns the index -> API-value mapping.
in property <int> filter-index: 0;
}
// Search actions wired to the Rust search controller.
@@ -384,8 +385,8 @@ export global SearchActions {
callback live(string);
// User picked a results tab (0..4).
callback tab-changed(int);
// searchType filter changed.
callback filter-changed(string);
// searchType filter changed — the new dropdown index.
callback filter-changed(int);
// Request the next page for the given tab.
callback load-more(int);
}