feat(slint): genre filter advanced view + child-genre filtering

Adds the advanced view to the Discover genre filter and recovers the
child-genre filtering the discovery-v2 home redesign dropped.

Child-genre recovery:
  - The old (pre-redesign) home expanded a parent selection to its
    descendant genre NAMES and filtered content client-side; the
    discovery-v2 redesign passed only the explicitly-selected ids to
    the discover index, so selecting a parent stopped covering its
    children. Here filter_ids() expands the selection to all loaded
    descendant ids and feeds that to get_discover_index; selecting a
    genre eager-loads its descendant subtree so the expansion is
    complete.

Advanced view:
  - Search box + a scrollable 3-level genre tree (parent / child /
    grandchild), flattened in Rust into visible GenreTreeRow rows
    honoring per-node expansion. Each row: indent by level, expand
    arrow (lazy-loads children on first expand), checkbox, name, and
    loaded child count. Search returns a flat match list across all
    loaded genres.
  - Opening the advanced view eager-loads every parent's children so
    counts show up front; grandchildren stay lazy until a child is
    expanded.

State/wiring:
  - GenreTreeRow struct; GenreFilterState gains tree + search-query;
    GenreFilterActions gains toggle-expand + search.
  - genre_filter STATE moves to LazyLock (HashMap/HashSet aren't
    const) and gains children map, expanded set, and search query.
  - main.rs wires toggle-expand (lazy child load), search, and the
    advanced-open eager child load; toggle now eager-loads
    descendants before the re-fetch.

cc
This commit is contained in:
vicrodh committed 2026-05-20 23:06:43 -06:00
1 parent 880493b73d
commit 9fce0939d1
4 files changed
+466 -32

No files matched your search

+220 -19
View File
@@ -7,15 +7,16 @@
//! selection" is on, and feeds `genre_ids` into the discover-index
//! fetch.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::{LazyLock, Mutex};
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use serde::{Deserialize, Serialize};
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::{AppWindow, GenreChip, GenreFilterState};
use crate::{AppWindow, GenreChip, GenreFilterState, GenreTreeRow};
#[derive(Clone)]
struct GenreItem {
@@ -37,14 +38,23 @@ fn default_true() -> bool {
struct State {
parents: Vec<GenreItem>,
/// Lazily loaded children, keyed by parent id (levels 2 and 3).
children: HashMap<u64, Vec<GenreItem>>,
selected: Vec<u64>,
expanded: HashSet<u64>,
search: String,
remember: bool,
}
static STATE: Mutex<State> = Mutex::new(State {
parents: Vec::new(),
selected: Vec::new(),
remember: true,
static STATE: LazyLock<Mutex<State>> = LazyLock::new(|| {
Mutex::new(State {
parents: Vec::new(),
children: HashMap::new(),
selected: Vec::new(),
expanded: HashSet::new(),
search: String::new(),
remember: true,
})
});
fn store_path() -> Option<PathBuf> {
@@ -82,12 +92,53 @@ fn save_persisted(selected: &[u64], remember: bool) {
}
}
/// The current shared genre-id selection. Read by navigate_home to
/// pass into the discover-index fetch.
/// The explicitly-selected genre ids (what the user clicked).
pub fn selected_ids() -> Vec<u64> {
STATE.lock().map(|s| s.selected.clone()).unwrap_or_default()
}
/// The ids to actually filter by: the selection plus every loaded
/// descendant of each selected genre. Selecting a parent therefore
/// covers its children/grandchildren — restoring the child-genre
/// filtering the discovery-v2 home redesign dropped (the old home
/// expanded to descendant names client-side; here we expand to
/// descendant ids for the server filter).
pub fn filter_ids() -> Vec<u64> {
let Ok(s) = STATE.lock() else {
return Vec::new();
};
let mut out: HashSet<u64> = HashSet::new();
for id in &s.selected {
out.insert(*id);
collect_descendants(&s.children, *id, &mut out);
}
out.into_iter().collect()
}
fn collect_descendants(
children: &HashMap<u64, Vec<GenreItem>>,
id: u64,
out: &mut HashSet<u64>,
) {
if let Some(kids) = children.get(&id) {
for kid in kids {
if out.insert(kid.id) {
collect_descendants(children, kid.id, out);
}
}
}
}
pub fn children_loaded(id: u64) -> bool {
STATE.lock().map(|s| s.children.contains_key(&id)).unwrap_or(false)
}
fn store_children(parent_id: u64, kids: Vec<GenreItem>) {
if let Ok(mut s) = STATE.lock() {
s.children.insert(parent_id, kids);
}
}
/// Fetch the parent genres (if not already loaded) and seed the
/// persisted selection. Runs on a worker; call apply_state afterwards
/// on the UI thread.
@@ -117,23 +168,101 @@ where
};
parents.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
// Drop any persisted ids that no longer exist.
let valid: Vec<u64> = persisted
.selected
.into_iter()
.filter(|id| parents.iter().any(|p| p.id == *id))
.collect();
// Keep persisted selections as-is — they may reference child
// genres not yet loaded (advanced view), so validating against
// parents only would wrongly drop them.
if let Ok(mut s) = STATE.lock() {
s.parents = parents;
s.selected = valid;
s.selected = persisted.selected;
s.remember = persisted.remember;
}
}
/// Push the current parents + selection into GenreFilterState. UI thread.
/// Load one genre level (children of `parent_id`) and store it. No-op
/// if already loaded.
pub async fn load_children<A>(runtime: &AppRuntime<A>, parent_id: u64)
where
A: FrontendAdapter + Send + Sync + 'static,
{
if children_loaded(parent_id) {
return;
}
let kids: Vec<GenreItem> = match runtime.core().get_genres(Some(parent_id)).await {
Ok(list) => list
.into_iter()
.map(|g| GenreItem { id: g.id, name: g.name })
.collect(),
Err(e) => {
log::warn!("[qbz-slint] genre filter: get_genres({parent_id}) failed: {e}");
Vec::new()
}
};
store_children(parent_id, kids);
}
fn child_ids(parent_id: u64) -> Vec<u64> {
STATE
.lock()
.ok()
.and_then(|s| s.children.get(&parent_id).map(|k| k.iter().map(|c| c.id).collect()))
.unwrap_or_default()
}
/// Eager-load every parent's children (level 2) so the advanced tree
/// can show child counts up front. Grandchildren stay lazy.
pub async fn load_all_parent_children<A>(runtime: &AppRuntime<A>)
where
A: FrontendAdapter + Send + Sync + 'static,
{
let parents: Vec<u64> = STATE
.lock()
.map(|s| s.parents.iter().map(|p| p.id).collect())
.unwrap_or_default();
for parent_id in parents {
load_children(runtime, parent_id).await;
}
}
/// Eager-load a genre's full descendant subtree (children +
/// grandchildren), so a selection expands correctly in filter_ids.
pub async fn load_descendants<A>(runtime: &AppRuntime<A>, id: u64)
where
A: FrontendAdapter + Send + Sync + 'static,
{
load_children(runtime, id).await;
for kid in child_ids(id) {
load_children(runtime, kid).await;
}
}
/// Toggle a tree node's expanded state. Returns true if it is now
/// expanded (so the caller can lazy-load its children).
pub fn toggle_expand(id_str: &str) -> bool {
let Ok(id) = id_str.parse::<u64>() else {
return false;
};
let Ok(mut s) = STATE.lock() else {
return false;
};
if s.expanded.contains(&id) {
s.expanded.remove(&id);
false
} else {
s.expanded.insert(id);
true
}
}
pub fn set_search(query: &str) {
if let Ok(mut s) = STATE.lock() {
s.search = query.to_string();
}
}
/// Push the current parents + selection + tree into GenreFilterState.
/// UI thread.
pub fn apply_state(window: &AppWindow) {
let (chips, count, remember) = {
let (chips, rows, count, remember) = {
let Ok(s) = STATE.lock() else {
return;
};
@@ -146,14 +275,86 @@ pub fn apply_state(window: &AppWindow) {
selected: s.selected.contains(&g.id),
})
.collect();
(chips, s.selected.len() as i32, s.remember)
(chips, build_tree_rows(&s), s.selected.len() as i32, s.remember)
};
let state = window.global::<GenreFilterState>();
state.set_genres(ModelRc::new(VecModel::from(chips)));
state.set_tree(ModelRc::new(VecModel::from(rows)));
state.set_selected_count(count);
state.set_remember(remember);
}
fn tree_row(item: &GenreItem, level: i32, s: &State) -> GenreTreeRow {
let loaded = s.children.get(&item.id);
let count = loaded.map(|c| c.len()).unwrap_or(0);
// Parents always have children; deeper levels show an expand
// arrow optimistically until a load proves them empty.
let has_children = if level == 0 {
true
} else if level == 1 {
count > 0 || loaded.is_none()
} else {
false
};
GenreTreeRow {
id: item.id.to_string().into(),
name: item.name.clone().into(),
level,
selected: s.selected.contains(&item.id),
expanded: s.expanded.contains(&item.id),
has_children,
count: count as i32,
}
}
/// Flatten the genre tree into the currently-visible rows. With a
/// search query, returns a flat list of all loaded genres matching
/// the query (ignoring expansion); otherwise honors per-node
/// expansion down three levels.
fn build_tree_rows(s: &State) -> Vec<GenreTreeRow> {
let query = s.search.trim().to_lowercase();
let mut rows: Vec<GenreTreeRow> = Vec::new();
if !query.is_empty() {
let matches = |g: &GenreItem| g.name.to_lowercase().contains(&query);
for p in &s.parents {
if matches(p) {
rows.push(tree_row(p, 0, s));
}
}
for kids in s.children.values() {
for k in kids {
if matches(k) {
rows.push(tree_row(k, 0, s));
}
}
}
return rows;
}
for parent in &s.parents {
rows.push(tree_row(parent, 0, s));
if !s.expanded.contains(&parent.id) {
continue;
}
let Some(children) = s.children.get(&parent.id) else {
continue;
};
for child in children {
rows.push(tree_row(child, 1, s));
if !s.expanded.contains(&child.id) {
continue;
}
if let Some(grandchildren) = s.children.get(&child.id) {
for gc in grandchildren {
rows.push(tree_row(gc, 2, s));
}
}
}
}
rows
}
/// Toggle a genre id in the selection. Returns true if the selection
/// changed (so the caller can re-fetch).
pub fn toggle(id_str: &str) -> bool {
+74 -3
View File
@@ -114,7 +114,9 @@ async fn reload_home(
image_cache: &artwork::ImageCache,
active_tab: String,
) {
let genre_ids = genre_filter::selected_ids();
// Expand the selection to descendants so a parent selection
// covers its child genres (the child-genre filtering recovery).
let genre_ids = genre_filter::filter_ids();
let genre_ids = (!genre_ids.is_empty()).then_some(genre_ids);
match home::load_home(runtime, genre_ids).await {
@@ -1786,6 +1788,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window
.global::<GenreFilterActions>()
.on_toggle(move |id| {
let was_selected = genre_filter::selected_ids()
.iter()
.any(|x| x.to_string() == id.as_str());
if !genre_filter::toggle(id.as_str()) {
return;
}
@@ -1795,14 +1800,65 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
genre_filter::apply_state(&w);
w.global::<HomeState>().set_loading(true);
let active = w.global::<HomeState>().get_active_tab().to_string();
let id = id.to_string();
let runtime = runtime.clone();
let weak = weak.clone();
let image_cache = image_cache.clone();
handle.spawn(async move {
// On a newly-selected genre, eager-load its descendants
// so filter_ids covers the child genres.
if !was_selected {
if let Ok(gid) = id.parse::<u64>() {
genre_filter::load_descendants(&runtime, gid).await;
let _ = weak.upgrade_in_event_loop(|w| {
genre_filter::apply_state(&w);
});
}
}
reload_home(&runtime, &weak, &image_cache, active).await;
});
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<GenreFilterActions>()
.on_toggle_expand(move |id| {
let now_expanded = genre_filter::toggle_expand(id.as_str());
let Some(w) = weak.upgrade() else {
return;
};
genre_filter::apply_state(&w);
// Lazy-load the node's children the first time it expands.
if now_expanded {
if let Ok(gid) = id.to_string().parse::<u64>() {
if !genre_filter::children_loaded(gid) {
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
genre_filter::load_children(&runtime, gid).await;
let _ = weak.upgrade_in_event_loop(|w| {
genre_filter::apply_state(&w);
});
});
}
}
}
});
}
{
let weak = window.as_weak();
window
.global::<GenreFilterActions>()
.on_search(move |query| {
genre_filter::set_search(query.as_str());
if let Some(w) = weak.upgrade() {
genre_filter::apply_state(&w);
}
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
@@ -1838,12 +1894,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<GenreFilterActions>()
.on_set_advanced(move |v| {
if let Some(w) = weak.upgrade() {
w.global::<GenreFilterState>().set_advanced(v);
let Some(w) = weak.upgrade() else {
return;
};
w.global::<GenreFilterState>().set_advanced(v);
// First time advanced view opens, eager-load every
// parent's children so the tree shows child counts.
if v {
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
genre_filter::load_all_parent_children(&runtime).await;
let _ = weak.upgrade_in_event_loop(|w| {
genre_filter::apply_state(&w);
});
});
}
});
}
@@ -7,10 +7,99 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { GenreFilterState, GenreFilterActions, GenreChip } from "../state.slint";
import { GenreFilterState, GenreFilterActions, GenreChip, GenreTreeRow } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
// One row of the advanced-view tree: indent + expand arrow + checkbox
// + name + child count.
component GenreTreeRowItem inherits Rectangle {
in property <GenreTreeRow> node;
callback toggle(string);
callback toggle-expand(string);
height: 34px;
border-radius: 6px;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 6px + root.node.level * 18px;
padding-right: 10px;
spacing: 6px;
// Expand arrow (parents / children with kids).
Rectangle {
width: 20px;
VerticalLayout {
alignment: center;
if root.node.has-children: QbzIcon {
source: root.node.expanded
? @image-url("../assets/icons/chevron-down.svg")
: @image-url("../assets/icons/chevron-right.svg");
width: 13px;
height: 13px;
tint: Theme.text-muted;
}
}
arrow-ta := TouchArea {
mouse-cursor: root.node.has-children ? pointer : default;
clicked => {
if (root.node.has-children) {
root.toggle-expand(root.node.id);
}
}
}
}
// Checkbox.
VerticalLayout {
alignment: center;
Rectangle {
width: 16px;
height: 16px;
border-radius: 4px;
border-width: 1.5px;
border-color: root.node.selected ? Theme.accent : Theme.border-muted;
background: root.node.selected ? Theme.accent : transparent;
QbzIcon {
visible: root.node.selected;
source: @image-url("../assets/icons/check.svg");
width: 11px;
height: 11px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #ffffff;
}
}
}
Text {
text: root.node.name;
color: root.node.selected ? Theme.text-primary : Theme.text-secondary;
font-size: 13px;
font-weight: root.node.level == 0 ? Typography.medium : Typography.regular;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
if root.node.count > 0: Text {
text: root.node.count;
color: Theme.text-muted;
font-size: Typography.legal;
vertical-alignment: center;
}
}
// Row click (outside the arrow) toggles selection.
ta := TouchArea {
mouse-cursor: pointer;
// Sits below the arrow's TouchArea, so arrow clicks win.
clicked => {
root.toggle(root.node.id);
}
}
}
component GenreCard inherits Rectangle {
in property <GenreChip> chip;
callback clicked(string);
@@ -171,15 +260,72 @@ export component GenreFilterPopup inherits Rectangle {
}
}
// --- Advanced view stub (tree/search/counts: next increment) -
if GenreFilterState.advanced: Rectangle {
height: 80px;
Text {
text: @tr("Advanced view coming soon.");
color: Theme.text-muted;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
// --- Advanced view: search + 3-level genre tree -------------
if GenreFilterState.advanced: VerticalLayout {
spacing: 10px;
// Search box.
Rectangle {
height: 34px;
border-radius: 6px;
background: Theme.surface-elevated;
border-width: 1px;
border-color: search-input.has-focus ? Theme.border-subtle : Theme.surface-elevated;
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 14px;
height: 14px;
tint: Theme.text-muted;
}
}
Rectangle {
horizontal-stretch: 1;
search-input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: 13px;
vertical-alignment: center;
single-line: true;
text <=> GenreFilterState.search-query;
edited => {
GenreFilterActions.search(self.text);
}
}
Text {
visible: search-input.text == "";
text: @tr("Search genres…");
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
}
}
}
}
// Tree (scrollable).
Flickable {
height: 320px;
viewport-width: self.width;
viewport-height: tree-col.preferred-height;
tree-col := VerticalLayout {
spacing: 1px;
for entry in GenreFilterState.tree: GenreTreeRowItem {
node: entry;
toggle(id) => {
GenreFilterActions.toggle(id);
}
toggle-expand(id) => {
GenreFilterActions.toggle-expand(id);
}
}
}
}
}
+16
View File
@@ -63,21 +63,37 @@ export struct GenreChip {
selected: bool,
}
// One row of the advanced-view genre tree, flattened (level 0/1/2)
// with only currently-visible rows emitted by the Rust side.
export struct GenreTreeRow {
id: string,
name: string,
level: int, // 0 parent, 1 child, 2 grandchild
selected: bool,
expanded: bool,
has-children: bool,
count: int, // loaded child count (0 = none / unknown)
}
// Filter-by-genre state. The selection is shared across the three
// Discover tabs (matching Tauri's single "home" genre context) — it
// re-fetches the discover index with the chosen genre ids. Populated
// by crate::genre_filter.
export global GenreFilterState {
in property <[GenreChip]> genres: [];
in property <[GenreTreeRow]> tree: [];
in property <int> selected-count: 0;
in-out property <bool> open: false;
in-out property <bool> remember: true;
in-out property <bool> advanced: false;
in-out property <string> search-query;
in property <bool> loading: false;
}
export global GenreFilterActions {
callback toggle(string /* genre id */);
callback toggle-expand(string /* genre id */);
callback search(string /* query */);
callback clear();
callback set-remember(bool);
callback set-advanced(bool);