Files
qbz/crates/qbz-slint/ui/myqbz/DiscographyBuilderView.slint
T
vicrodh 4e0d1a005e fix(myqbz): final fidelity pass (uppercase, trim, expanded cache, bulk bar §13.1)
Phase-2 Slice 12d.
- Discography Builder: uppercase eyebrow + ORDER BY + 6 column labels (BUILD ARTIST
  COLLECTION / YEAR/TYPE/ALBUM/TRACKS/SOURCE/QUALITY); Create now disables on a
  whitespace-only name (name-trimmed-empty computed in the controller).
- Expanded-mode: per-item inline-track cache keyed by the stable source|source_item_id
  (survives filter/sort/search re-derive instead of re-fetching), cleared on open.
- Inline tracks: go-to-album passes the parent item_type (playlist parent routes to
  the playlist view); duration 0 -> "--:--"; track number from the resolver order.
- Detail bulk bar: completed the §13.1 set (Add to queue, Play next, Add to playlist,
  Add to Mixtape/Collection, Remove, Clear) via MultiSelectBar slots + bulk_enqueue
  (reuses the resolver) + the existing playlist picker; no invented primitive.
- Stale comments corrected (dj-mix wired; mix-modal primary = Add to queue).

Backend crates untouched. cargo check -p qbz-slint green.

cc
2026-06-09 08:49:39 -06:00

867 lines
38 KiB
Plaintext

// Discography Builder — the full-page flow that assembles an artist's
// complete discography into a saved `kind='artist_collection'` collection
// (spec 13). A 1:1 port of the Tauri DiscographyBuilderView.svelte.
//
// Two-part layout: `.builder-scroll` holds everything that scrolls; the
// footer is a fixed-height flex sibling pinned at the bottom (NOT
// sticky-inside-scroll) so last rows never peek past the footer.
//
// CSS variable values are resolved to the concrete dark-theme values from
// spec §0 (this view hardcodes them rather than going through the Theme
// global so the port stays pixel-exact to the quoted PSD).
import { Typography } from "../foundation/typography.slint";
import { DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
// ── Concrete dark-theme values (spec §0) ──────────────────────────────────
// Resolved here as global consts so the whole view shares one source.
global D {
out property <color> bg-primary: #0f0f0f; // footer bg
out property <color> bg-secondary: #1a1a1a; // input/segment/menu/secondary-btn
out property <color> bg-tertiary: #2a2a2a; // avatar placeholder, hairlines
out property <color> bg-hover: #1f1f1f; // row/segment/menu-item hover
out property <color> text-primary: #ffffff;
out property <color> text-secondary: #cccccc;
out property <color> text-muted: #888888;
out property <color> accent: #4285F4;
out property <color> btn-primary-text: #ffffff;
out property <color> alpha-6: #ffffff0f; // rgba(255,255,255,0.06)
out property <color> error: #e57373;
// Type-label tints (hardcoded non-var colors, spec §0).
out property <color> tint-compilation: #f59e0b; // amber
out property <color> tint-live: #e91e63; // pink
out property <color> tint-ep: #60a5fa; // blue
out property <color> tint-single: #a78bfa; // violet
}
// Resolve a release-type to its tint (plain "album" = muted, no tint).
component TypeColor {
in property <string> kind;
out property <color> color:
root.kind == "compilation" ? D.tint-compilation
: root.kind == "live" ? D.tint-live
: root.kind == "ep" ? D.tint-ep
: root.kind == "single" ? D.tint-single
: D.text-muted;
}
// ── Source indicator (Qobuz / Plex / Local accent-tinted silhouette) ──────
component SourceIndicator inherits Rectangle {
in property <string> source;
width: 28px;
height: 28px;
opacity: 0.9;
HorizontalLayout {
alignment: center;
VerticalLayout {
alignment: center;
if root.source == "plex": QbzIcon {
source: @image-url("../assets/icons/plex-logo.svg");
width: 14px;
height: 14px;
tint: D.accent;
}
if root.source == "qobuz": QbzIcon {
source: @image-url("../assets/icons/qobuz-logo-filled.svg");
width: 16px;
height: 16px;
tint: D.accent;
}
if root.source != "plex" && root.source != "qobuz": QbzIcon {
source: @image-url("../assets/icons/hard-drive.svg");
width: 14px;
height: 14px;
tint: D.accent;
}
}
}
}
// ── Per-row release-type override button + popover (spec §7.1) ─────────────
component TypeOverride inherits Rectangle {
in property <DiscoCandidate> candidate;
// 11px primary / 10px alternate.
in property <length> font-size: 11px;
property <string> menu-key: root.candidate.source + "|" + root.candidate.source-item-id;
property <bool> menu-open: DiscoBuilderState.open-type-menu-key == root.menu-key;
// The wrapper anchors the absolute popover; it sizes to the button.
width: type-btn.preferred-width;
height: type-btn.preferred-height;
type-btn := Rectangle {
// .type-btn negative margin (0 -4px) is replicated by a -4px touch
// bleed; the visual glyph stays put.
background: btn-ta.has-hover ? D.alpha-6 : transparent;
border-radius: 4px;
HorizontalLayout {
padding-left: 4px;
padding-right: 4px;
padding-top: 2px;
padding-bottom: 2px;
label := Text {
text: root.candidate.release-type;
color: type-color.color;
font-size: root.font-size;
font-weight: 600;
letter-spacing: 0.4px;
vertical-alignment: center;
}
}
type-color := TypeColor { kind: root.candidate.release-type; }
// Dotted-underline marker for an active override (Slint has no
// text-decoration; draw a 1px dotted rule under the label).
if root.candidate.is-overridden: Rectangle {
x: label.x;
y: parent.height - 3px;
width: label.width;
height: 1px;
background: type-color.color;
opacity: 0.6;
}
btn-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderState.open-type-menu-key =
root.menu-open ? "" : root.menu-key;
}
}
}
// Backdrop + menu (rendered above siblings; z-order is paint order).
if root.menu-open: Rectangle {
// Backdrop click-catcher — full-row local catch; closes the menu.
// (A true full-viewport backdrop isn't expressible here without a
// global overlay; the menu's close-on-outside is handled by the
// backdrop TouchArea plus re-clicking the button.)
TouchArea {
x: -2000px;
y: -2000px;
width: 4000px;
height: 4000px;
clicked => {
DiscoBuilderState.open-type-menu-key = "";
}
}
menu := Rectangle {
x: 0;
y: parent.height + 4px;
width: 160px;
background: D.bg-secondary;
border-width: 1px;
border-color: D.bg-tertiary;
border-radius: 6px;
drop-shadow-blur: 12px;
drop-shadow-color: #00000059; // rgba(0,0,0,0.35)
drop-shadow-offset-y: 4px;
VerticalLayout {
padding: 4px;
// Album, EP, Single, Live, Compilation (spec order).
for choice in [
{ id: "album", lbl: "Album" },
{ id: "ep", lbl: "EP" },
{ id: "single", lbl: "Single" },
{ id: "live", lbl: "Live" },
{ id: "compilation", lbl: "Compilation" },
]: Rectangle {
height: 28px;
border-radius: 4px;
background: item-ta.has-hover ? D.bg-hover : transparent;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
alignment: start;
VerticalLayout {
alignment: center;
// Check glyph if this is the selected type; else a
// 12px placeholder so labels stay aligned.
if root.candidate.release-type == choice.id: QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 12px;
height: 12px;
tint: D.accent;
}
if root.candidate.release-type != choice.id: Rectangle {
width: 12px;
height: 12px;
}
}
Text {
text: choice.lbl;
color: root.candidate.release-type == choice.id
? D.accent : D.text-primary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
item-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.set-type-override(
root.candidate.source,
root.candidate.source-item-id,
choice.id);
DiscoBuilderState.open-type-menu-key = "";
}
}
}
// Reset row — only when an override is active.
if root.candidate.is-overridden: Rectangle {
height: 9px;
Rectangle {
y: 4px;
height: 1px;
background: D.bg-tertiary;
}
}
if root.candidate.is-overridden: Rectangle {
height: 28px;
border-radius: 4px;
background: reset-ta.has-hover ? D.bg-hover : transparent;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/rotate-ccw.svg");
width: 12px;
height: 12px;
tint: D.text-muted;
}
}
Text {
text: @tr("Use auto-detected");
color: D.text-muted;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
reset-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.reset-type-override(
root.candidate.source,
root.candidate.source-item-id);
DiscoBuilderState.open-type-menu-key = "";
}
}
}
}
}
}
}
// 7-column row grid — the alignment backbone (spec §2.9):
// grid-template-columns: 28px 56px 72px minmax(0,1fr) 72px 60px 156px, gap 10px.
// Each row below inlines this template (header/primary/alternate share it).
//
// One candidate row (primary or alternate). `alt` dims it + swaps the col[0]
// checkbox for a connector + checkbox and shrinks/mutes the text.
component CandidateRow inherits Rectangle {
in property <DiscoCandidate> candidate;
in property <string> group-key;
in property <bool> alt: false;
// Whether the OWNING group is a compilation — drives the primary row's
// "compilation" tag (spec §2.10: tag is `{#if grp.is_compilation}`, the
// group flag, not the candidate's effective type).
in property <bool> group-is-compilation: false;
// Persistent dim (compilation primary 0.65 / alternate 0.72); lifts to 1
// on hover. The parent passes the resting opacity.
in property <float> rest-opacity: 1.0;
height: 36px;
border-radius: 6px;
background: row-ta.has-hover ? D.bg-hover : transparent;
opacity: row-ta.has-hover ? 1.0 : root.rest-opacity;
row-ta := TouchArea { mouse-cursor: default; }
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 10px;
// [0] 28px — checkbox cell (alt: connector + checkbox).
Rectangle {
width: 28px;
HorizontalLayout {
alignment: start;
spacing: 3px;
if root.alt: Text {
text: "↳";
color: D.text-muted;
font-size: 12px;
vertical-alignment: center;
}
VerticalLayout {
alignment: center;
Rectangle {
width: 15px;
height: 15px;
border-radius: 3px;
background: root.candidate.checked ? D.accent : transparent;
border-width: root.candidate.checked ? 0px : 1.5px;
border-color: D.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: D.btn-primary-text;
}
TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.toggle-checked(
root.group-key, root.candidate.key);
}
}
}
}
}
}
// [1] 56px — year.
Rectangle {
width: 56px;
Text {
text: root.candidate.year;
color: root.alt ? D.text-muted : D.text-secondary;
font-size: root.alt ? 12px : 13px;
vertical-alignment: center;
horizontal-alignment: left;
width: 100%;
}
}
// [2] 72px — type override button + popover.
Rectangle {
width: 72px;
HorizontalLayout {
alignment: start;
VerticalLayout {
alignment: center;
TypeOverride {
candidate: root.candidate;
font-size: root.alt ? 10px : 11px;
}
}
}
}
// [3] 1fr — title (flexes) + tag.
Rectangle {
horizontal-stretch: 1;
HorizontalLayout {
alignment: start;
spacing: 8px;
title-ta := TouchArea {
mouse-cursor: pointer;
horizontal-stretch: 1;
clicked => {
DiscoBuilderActions.open-album(
root.candidate.source, root.candidate.source-item-id);
}
title := Text {
text: root.candidate.title;
color: title-ta.has-hover
? D.accent
: (root.alt ? D.text-muted : D.text-primary);
font-size: root.alt ? 13px : 14px;
overflow: elide;
vertical-alignment: center;
width: 100%;
}
}
// alternates always show the "alternate" tag; primaries show
// "compilation" only when the group is a compilation (passed
// by the parent baking it into the candidate's title? No —
// the parent sets `tag` separately). Here we infer from the
// row kind + the candidate's own type.
if root.alt: Text {
text: @tr("alternate");
color: D.text-muted;
font-size: 10px;
font-weight: 500;
font-italic: true;
letter-spacing: 0.8px;
vertical-alignment: center;
}
if !root.alt && root.group-is-compilation: Text {
text: @tr("compilation");
color: D.text-muted;
font-size: 10px;
font-weight: 500;
font-italic: true;
letter-spacing: 0.8px;
vertical-alignment: center;
}
}
}
// [4] 72px — tracks (centered, tabular).
Rectangle {
width: 72px;
Text {
text: root.candidate.tracks;
color: root.alt ? D.text-muted : D.text-secondary;
font-size: root.alt ? 12px : 13px;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
}
}
// [5] 60px — source indicator.
Rectangle {
width: 60px;
HorizontalLayout {
alignment: center;
VerticalLayout {
alignment: center;
SourceIndicator { source: root.candidate.source; }
}
}
}
// [6] 156px — quality badge.
Rectangle {
width: 156px;
HorizontalLayout {
alignment: center;
VerticalLayout {
alignment: center;
QualityBadgeFull {
tier: root.candidate.quality-tier;
detail: root.candidate.quality-detail;
}
}
}
}
}
}
export component DiscographyBuilderView inherits Rectangle {
background: transparent;
// ── .builder-view (flex column, full size) ──
VerticalLayout {
// ── .builder-scroll (scrolls everything) ──
scroll := Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: content.preferred-height;
content := VerticalLayout {
width: scroll.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 24px;
padding-bottom: 24px;
spacing: 0px;
alignment: start;
// ── .back-btn ──
Rectangle {
height: 20px;
horizontal-stretch: 0;
back-ta := TouchArea {
width: back-row.preferred-width;
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.back();
}
back-row := HorizontalLayout {
spacing: 8px;
alignment: start;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/arrow-left.svg");
width: 16px;
height: 16px;
tint: back-ta.has-hover ? D.text-secondary : D.text-muted;
}
}
Text {
text: @tr("Back");
color: back-ta.has-hover ? D.text-secondary : D.text-muted;
font-size: 14px;
vertical-alignment: center;
}
}
}
}
Rectangle { height: 24px; }
// ── header.builder-header ──
HorizontalLayout {
spacing: 20px;
alignment: start;
// Avatar (img or placeholder) — 72px circle.
Rectangle {
width: 72px;
height: 72px;
border-radius: 36px;
background: D.bg-tertiary;
clip: true;
if DiscoBuilderState.artist-avatar-url != "": Image {
width: 100%;
height: 100%;
source: DiscoBuilderState.artist-avatar;
image-fit: cover;
}
}
// Header text — eyebrow + title.
VerticalLayout {
alignment: center;
spacing: 4px;
horizontal-stretch: 1;
Text {
// Eyebrow is uppercased (spec 13 §0: eyebrow/field-
// label/col-label use text-transform:uppercase). Slint
// has no text-transform, so the literal is written in
// caps (same idiom as the other MyQBZ eyebrows —
// AddToMixtapeModal "ADD TO MIXTAPE/COLLECTION", the
// grid card label from Rust kind_label).
text: @tr("BUILD ARTIST COLLECTION");
color: D.text-muted;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.5px;
}
Text {
text: DiscoBuilderState.artist-name == ""
? "—" : DiscoBuilderState.artist-name;
color: D.text-primary;
font-size: 28px;
font-weight: 700;
overflow: elide;
}
}
}
Rectangle { height: 24px; }
// ── label.field (collection-name input) ──
VerticalLayout {
spacing: 8px;
alignment: start;
Text {
text: @tr("NAME");
color: D.text-muted;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.5px;
}
Rectangle {
width: 540px;
height: 40px;
background: D.bg-secondary;
border-radius: 8px;
border-width: 1px;
border-color: name-input.has-focus ? D.accent : D.bg-tertiary;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
name-input := TextInput {
color: D.text-primary;
font-size: 14px;
vertical-alignment: center;
single-line: true;
text: DiscoBuilderState.collection-name;
edited => {
DiscoBuilderActions.name-changed(self.text);
}
}
}
}
}
Rectangle { height: 16px; }
// ── .order-row (segmented control) ──
HorizontalLayout {
spacing: 12px;
alignment: start;
VerticalLayout {
alignment: center;
Text {
// .field-label is uppercased (spec §2.6 — same
// text-transform:uppercase as the eyebrow / NAME label).
text: @tr("ORDER BY");
color: D.text-muted;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.5px;
}
}
// .segmented — 8px corners, NO pills.
Rectangle {
width: seg.preferred-width;
height: 32px;
border-radius: 8px;
border-width: 1px;
border-color: D.bg-tertiary;
clip: true;
seg := HorizontalLayout {
spacing: 0px;
for s[i] in [
{ id: "release_date", lbl: "Release date" },
{ id: "title", lbl: "Title" },
{ id: "manual", lbl: "Manual" },
]: seg-rect := Rectangle {
property <bool> active: DiscoBuilderState.order-by == s.id;
background: seg-rect.active
? D.accent
: (seg-ta.has-hover ? D.bg-hover : D.bg-secondary);
// Divider between segments.
if i > 0: Rectangle {
x: 0;
width: 1px;
height: 100%;
background: D.bg-tertiary;
}
HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 7px;
padding-bottom: 7px;
Text {
text: s.lbl;
color: seg-rect.active ? D.btn-primary-text
: (seg-ta.has-hover ? D.text-primary : D.text-secondary);
font-size: 12px;
font-weight: 500;
vertical-alignment: center;
}
}
seg-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.set-order(s.id);
}
}
}
}
}
}
Rectangle { height: 20px; }
// ── {state branch} ──
// loading
if DiscoBuilderState.loading: Rectangle {
height: 96px;
Text {
text: @tr("Loading...");
color: D.text-muted;
font-size: 14px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// error
if !DiscoBuilderState.loading && DiscoBuilderState.load-error != "": Rectangle {
height: 96px;
Text {
text: DiscoBuilderState.load-error;
color: D.error;
font-size: 14px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// empty
if !DiscoBuilderState.loading
&& DiscoBuilderState.load-error == ""
&& DiscoBuilderState.groups.length == 0: Rectangle {
height: 96px;
Text {
text: @tr("No results found");
color: D.text-muted;
font-size: 14px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// populated — .groups table
if !DiscoBuilderState.loading
&& DiscoBuilderState.load-error == ""
&& DiscoBuilderState.groups.length > 0: VerticalLayout {
spacing: 0px;
// border-top + 4px pad-top.
Rectangle { height: 1px; background: D.bg-tertiary; }
Rectangle { height: 4px; }
// ── header row (.row.header-row) ──
Rectangle {
height: 36px;
Rectangle {
y: parent.height - 1px;
height: 1px;
background: D.bg-tertiary;
}
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 10px;
// [0] 28px — select-all checkbox (tri-state).
Rectangle {
width: 28px;
HorizontalLayout {
alignment: start;
VerticalLayout {
alignment: center;
Rectangle {
width: 15px;
height: 15px;
border-radius: 3px;
background: DiscoBuilderState.all-checked
? D.accent : transparent;
border-width: DiscoBuilderState.all-checked ? 0px : 1.5px;
border-color: D.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: D.btn-primary-text;
}
// Indeterminate dash.
if !DiscoBuilderState.all-checked
&& DiscoBuilderState.some-checked: Rectangle {
width: 9px;
height: 2px;
background: D.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();
}
}
}
}
}
}
// [1..6] column labels — uppercased (spec §2.9
// header-row text-transform:uppercase; literal caps).
Rectangle {
width: 56px;
Text { text: @tr("YEAR"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; }
}
Rectangle {
width: 72px;
Text { text: @tr("TYPE"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: left; }
}
Rectangle {
horizontal-stretch: 1;
Text { text: @tr("ALBUM"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; horizontal-alignment: left; }
}
Rectangle {
width: 72px;
Text { text: @tr("TRACKS"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
}
Rectangle {
width: 60px;
Text { text: @tr("SOURCE"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
}
Rectangle {
width: 156px;
Text { text: @tr("QUALITY"); color: D.text-muted; font-size: 10px; font-weight: 600; letter-spacing: 1.2px; vertical-alignment: center; width: 100%; horizontal-alignment: center; }
}
}
}
// ── per group ──
for grp in DiscoBuilderState.groups: VerticalLayout {
spacing: 0px;
// primary row (dimmed when the group is a compilation).
CandidateRow {
candidate: grp.primary;
group-key: grp.key;
alt: false;
group-is-compilation: grp.is-compilation;
rest-opacity: grp.is-compilation ? 0.65 : 1.0;
}
// alternate rows.
for alt in grp.alternates: CandidateRow {
candidate: alt;
group-key: grp.key;
alt: true;
rest-opacity: 0.72;
}
}
}
}
}
// ── footer.builder-footer (fixed-height flex sibling) ──
Rectangle {
vertical-stretch: 0;
height: 60px;
background: D.bg-primary;
Rectangle {
y: 0;
height: 1px;
background: D.bg-tertiary;
}
HorizontalLayout {
padding-left: 32px;
padding-right: 88px; // asymmetric — clears the back-to-top FAB.
padding-top: 12px;
padding-bottom: 12px;
spacing: 16px;
// .footer-count.
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: DiscoBuilderState.selected-count + " of "
+ DiscoBuilderState.group-count + " albums selected";
color: D.text-secondary;
font-size: 13px;
}
}
// .footer-actions — the app's shared labeled primitives.
HorizontalLayout {
spacing: 8px;
alignment: end;
// disabled = creating || selectedCount===0 ||
// !collectionName.trim() (spec §2.13 / §10.19). The trim is
// computed in the controller (Slint can't trim) and exposed
// as `name-trimmed-empty`, so a whitespace-only name disables.
property <bool> create-disabled:
DiscoBuilderState.creating
|| DiscoBuilderState.selected-count == 0
|| DiscoBuilderState.name-trimmed-empty;
// Cancel (secondary).
SecondaryButton {
label: @tr("Cancel");
height: 36px;
clicked => {
DiscoBuilderActions.back();
}
}
// Create (primary).
QbzPrimaryButton {
label: DiscoBuilderState.creating
? @tr("Loading...") : @tr("Create Collection");
height: 36px;
enabled: !parent.create-disabled;
clicked => {
DiscoBuilderActions.create();
}
}
}
}
}
}
}