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:
4 files changed
+466
-32
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user