fix(myqbz): visual round 2 — local art, checkbox-filter, circular checkboxes, quality skeleton, album header

Per the user's 2nd visual review:
- Local/Plex album art now renders in the detail rows: resolve_items backfills
  empty-art rows from the first resolved track (source-aware dispatch ->
  spawn_local_or_plex_loads with live Plex creds); to_item re-hydrates from cache.
- Type/source FILTER homologated: a LABELED filter button (FavGenreButton idiom,
  accent-fill active) + a checkbox popup; the "Select" toggle is now ICON-ONLY
  (square-check-big, no text) matching Favorites/LocalLibrary.
- New primitives/SelectionCheckbox.slint (the canonical CIRCULAR 13px selection
  checkbox, accent fill, check / minus for indeterminate). Replaced all 5 square
  MyQBZ checkboxes (detail list+grid, filter popup, builder per-row + tri-state
  select-all). KindRadio dots stay round radios (single-select, correct).
- Quality column shows a LoadingDots skeleton while resolving (not empty).
- Album view header buttons: deleted AlbumPageView's private divergent CircleAction
  copy -> the shared primitive, so it matches every other view's thickness.

CollectionsView kind-filter stays QbzSelect (single-select group-by = the app's
group-by idiom). Backend crates untouched. cargo check -p qbz-slint green.

cc
This commit is contained in:
vicrodh committed 2026-06-09 13:31:31 -06:00
1 parent 0b20a1a86a
commit ea517326b7
7 files changed
+274 -223

No files matched your search

@@ -20,6 +20,7 @@ import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
import { SourceGlyph } from "../primitives/SourceGlyph.slint";
import { SelectionCheckbox } from "../primitives/SelectionCheckbox.slint";
// ── Release-type label tints (spec §0) ────────────────────────────────────
// The ONE legitimately-hardcoded palette in this view: the per-release-type
@@ -289,27 +290,11 @@ component CandidateRow inherits Rectangle {
}
VerticalLayout {
alignment: center;
Rectangle {
width: 15px;
height: 15px;
border-radius: 3px;
background: root.candidate.checked ? Theme.accent : transparent;
border-width: root.candidate.checked ? 0px : 1.5px;
border-color: Theme.text-muted;
if root.candidate.checked: QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 11px;
height: 11px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.accent-text;
}
TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.toggle-checked(
root.group-key, root.candidate.key);
}
SelectionCheckbox {
checked: root.candidate.checked;
toggled => {
DiscoBuilderActions.toggle-checked(
root.group-key, root.candidate.key);
}
}
}
@@ -665,43 +650,22 @@ export component DiscographyBuilderView inherits Rectangle {
padding-left: 8px;
padding-right: 8px;
spacing: 10px;
// [0] 28px — select-all checkbox (tri-state).
// [0] 28px — select-all checkbox (tri-state). The
// indeterminate state renders minus.svg inside the
// same circular accent box (the TreeRow idiom), NOT a
// hand-drawn dash.
Rectangle {
width: 28px;
HorizontalLayout {
alignment: start;
VerticalLayout {
alignment: center;
Rectangle {
width: 15px;
height: 15px;
border-radius: 3px;
background: DiscoBuilderState.all-checked
? Theme.accent : transparent;
border-width: DiscoBuilderState.all-checked ? 0px : 1.5px;
border-color: Theme.text-muted;
if DiscoBuilderState.all-checked: QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 11px;
height: 11px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.accent-text;
}
// Indeterminate dash.
if !DiscoBuilderState.all-checked
&& DiscoBuilderState.some-checked: Rectangle {
width: 9px;
height: 2px;
background: Theme.accent;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.toggle-all();
}
SelectionCheckbox {
checked: DiscoBuilderState.all-checked;
partial: !DiscoBuilderState.all-checked
&& DiscoBuilderState.some-checked;
toggled => {
DiscoBuilderActions.toggle-all();
}
}
}
@@ -35,13 +35,18 @@ import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { SourceGlyph } from "../primitives/SourceGlyph.slint";
import { SelectionCheckbox } from "../primitives/SelectionCheckbox.slint";
import { LoadingDots } from "../primitives/LoadingDots.slint";
import { CollectionMosaic } from "CollectionMosaic.slint";
// --- One type / source filter dropdown row ------------------------------
// --- One type / source filter CHECKBOX row ------------------------------
// A leading circular SelectionCheckbox (the app-wide multi-select idiom) + a
// label. Used for both the single-select Type section (exclusive — picking one
// unchecks the others, handled controller-side) and the multi-select Source
// section. Replaces the old text+trailing-check dropdown row.
component MenuOption inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
in property <bool> check: false; // trailing Check (filter selection)
callback chosen();
height: 30px;
border-radius: 4px;
@@ -50,6 +55,13 @@ component MenuOption inherits Rectangle {
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
VerticalLayout {
alignment: center;
SelectionCheckbox {
checked: root.selected;
toggled => { root.chosen(); }
}
}
Text {
text: root.label;
color: root.selected ? Theme.accent : Theme.text-primary;
@@ -57,15 +69,6 @@ component MenuOption inherits Rectangle {
vertical-alignment: center;
horizontal-stretch: 1;
}
if root.check && root.selected: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 12px;
height: 12px;
tint: Theme.accent;
}
}
}
mo-ta := TouchArea {
mouse-cursor: pointer;
@@ -84,13 +87,20 @@ component ToolBtn inherits Rectangle {
in property <string> label; // "" -> icon-only
in property <int> badge: 0; // filter count badge (0 = hidden)
in property <bool> active: false;
// Labeled-active idiom (the Favorites FavGenreButton): active = accent FILL +
// white icon/text. When false (icon-only ToolBtns: select/reset), active is
// the LocalLibrary accent-BORDER idiom instead. Defaults to whichever fits
// the label state so callers don't have to set it.
in property <bool> fill-active: root.label != "";
callback clicked();
height: 30px;
border-radius: 6px;
width: row.preferred-width + (root.label == "" ? 16px : 20px);
background: cb-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
background: (root.active && root.fill-active)
? Theme.accent
: (cb-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
border-width: 1px;
border-color: root.active ? Theme.accent : Theme.border-subtle;
border-color: (root.active && !root.fill-active) ? Theme.accent : Theme.border-subtle;
row := HorizontalLayout {
x: root.label == "" ? 8px : 10px;
height: 100%;
@@ -102,12 +112,16 @@ component ToolBtn inherits Rectangle {
source: root.icon;
width: 15px;
height: 15px;
tint: root.active ? Theme.text-primary : Theme.text-muted;
tint: (root.active && root.fill-active)
? #ffffff
: (root.active ? Theme.text-primary : Theme.text-muted);
}
}
if root.label != "": Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-secondary;
color: (root.active && root.fill-active)
? #ffffff
: (root.active ? Theme.text-primary : Theme.text-secondary);
font-size: 12px;
vertical-alignment: center;
}
@@ -115,10 +129,10 @@ component ToolBtn inherits Rectangle {
width: 16px;
height: 16px;
border-radius: 8px;
background: Theme.accent;
background: (root.active && root.fill-active) ? #ffffff : Theme.accent;
Text {
text: root.badge;
color: Theme.accent-text;
color: (root.active && root.fill-active) ? Theme.accent : Theme.accent-text;
font-size: 10px;
font-weight: Typography.semibold;
horizontal-alignment: center;
@@ -190,27 +204,11 @@ component DetailRow inherits Rectangle {
// Col 1 — # / checkbox (40px).
Rectangle {
width: 40px;
if root.select-mode: Rectangle {
width: 15px;
height: 15px;
if root.select-mode: SelectionCheckbox {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: 3px;
border-width: 1px;
border-color: root.item.selected ? Theme.accent : Theme.text-muted;
background: root.item.selected ? Theme.accent : transparent;
if root.item.selected: QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 11px;
height: 11px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.accent-text;
}
cb-ta := TouchArea {
mouse-cursor: pointer;
clicked => { MyQbzDetailActions.toggle-item-select(root.item.position); }
}
checked: root.item.selected;
toggled => { MyQbzDetailActions.toggle-item-select(root.item.position); }
}
if !root.select-mode: Text {
text: root.index;
@@ -357,7 +355,9 @@ component DetailRow inherits Rectangle {
}
// Col 5 — quality (160px). Fed from the resolveItems pass (the row's
// album-level quality, derived from its first resolved track).
// album-level quality, derived from its first resolved track). While
// that async resolve is pending, a LoadingDots skeleton (the app's
// loading idiom) fills the cell instead of leaving it empty.
Rectangle {
width: 160px;
if root.item.quality-tier != "": QualityBadgeFull {
@@ -366,6 +366,10 @@ component DetailRow inherits Rectangle {
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
if root.item.quality-tier == "" && root.item.quality-resolving: LoadingDots {
x: 0;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
// Col 6 — tracks (72px, right-aligned).
@@ -505,27 +509,21 @@ component DetailCard inherits Rectangle {
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
// Select checkbox (top-left) OR hover play overlay.
// Select checkbox (top-left) OR hover play overlay. The circular
// checkbox sits on a dark disc so it stays legible over a bright
// cover (the row variant rides surface-hover and needs no backing).
if root.select-mode: Rectangle {
x: 8px;
y: 8px;
width: 18px;
height: 18px;
border-radius: 3px;
background: root.item.selected ? Theme.accent : #0000008c;
border-width: 1px;
border-color: root.item.selected ? Theme.accent : Theme.text-muted;
if root.item.selected: QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 12px;
height: 12px;
x: 6px;
y: 6px;
width: 22px;
height: 22px;
border-radius: 11px;
background: root.item.selected ? transparent : #0000008c;
SelectionCheckbox {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.accent-text;
}
gcb-ta := TouchArea {
mouse-cursor: pointer;
clicked => { MyQbzDetailActions.toggle-item-select(root.item.position); }
checked: root.item.selected;
toggled => { MyQbzDetailActions.toggle-item-select(root.item.position); }
}
}
if !root.select-mode: Rectangle {
@@ -896,15 +894,20 @@ export component MixtapeDetailView inherits Rectangle {
}
}
// Type + source filter (bordered icon button + badge, the
// LocalLibrary quality-filter idiom; multi-section popup).
// Type + source filter — LABELED filter button (the
// Favorites FavGenreButton idiom: icon + text, active =
// accent fill + "N filters") opening a CHECKBOX popup.
Rectangle {
width: filter-btn.width;
VerticalLayout {
alignment: center;
filter-btn := ToolBtn {
icon: @image-url("../assets/icons/list-filter.svg");
badge: MyQbzDetailState.filter-count;
label: MyQbzDetailState.filter-count > 0
? (MyQbzDetailState.filter-count == 1
? @tr("1 filter")
: @tr("{} filters", MyQbzDetailState.filter-count))
: @tr("Filter");
active: MyQbzDetailState.filter-count > 0;
clicked => { filter-menu.show(); }
}
@@ -936,25 +939,21 @@ export component MixtapeDetailView inherits Rectangle {
MenuOption {
label: @tr("All types");
selected: MyQbzDetailState.type-filter == "all";
check: true;
chosen => { MyQbzDetailActions.set-type-filter("all"); }
}
MenuOption {
label: @tr("Album");
selected: MyQbzDetailState.type-filter == "album";
check: true;
chosen => { MyQbzDetailActions.set-type-filter("album"); }
}
MenuOption {
label: @tr("Track");
selected: MyQbzDetailState.type-filter == "track";
check: true;
chosen => { MyQbzDetailActions.set-type-filter("track"); }
}
MenuOption {
label: @tr("Playlist");
selected: MyQbzDetailState.type-filter == "playlist";
check: true;
chosen => { MyQbzDetailActions.set-type-filter("playlist"); }
}
Rectangle { height: 1px; background: Theme.surface-elevated; }
@@ -972,19 +971,16 @@ export component MixtapeDetailView inherits Rectangle {
MenuOption {
label: @tr("Qobuz");
selected: MyQbzDetailState.src-qobuz;
check: true;
chosen => { MyQbzDetailActions.toggle-source-filter("qobuz"); }
}
MenuOption {
label: @tr("Plex");
selected: MyQbzDetailState.src-plex;
check: true;
chosen => { MyQbzDetailActions.toggle-source-filter("plex"); }
}
MenuOption {
label: @tr("Local");
selected: MyQbzDetailState.src-local;
check: true;
chosen => { MyQbzDetailActions.toggle-source-filter("local"); }
}
}
@@ -1002,12 +998,13 @@ export component MixtapeDetailView inherits Rectangle {
}
}
// Select / Cancel.
// Select / Cancel — ICON-ONLY (no text label), matching the
// app's Favorites/LocalLibrary multi-select toggle. `active`
// (select-mode on) is the only state cue.
VerticalLayout {
alignment: center;
ToolBtn {
icon: @image-url("../assets/icons/square-check-big.svg");
label: MyQbzDetailState.select-mode ? @tr("Cancel") : @tr("Select");
active: MyQbzDetailState.select-mode;
clicked => { MyQbzDetailActions.toggle-select-mode(); }
}