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

@@ -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);
}
}
}
}
}