refactor(myqbz): button audit — app primitives + fix glyph centering

Phase-2 Slice 12c (user directive: use the app's circular controls, no invented
buttons; fix the glyph-stuck-left bug).
- Hero §3.6: deleted the local ActionCircle; the 4 hero controls are now the
  shared CircleAction (Play primary = filled white disc + black glyph; the loading
  spinner is now available = more 1:1). Added `enabled` to CircleAction (additive).
- All MyQBZ modal footers (Create / Edit / Mix / AddTo / Discography) now use the
  shared QbzPrimaryButton (+ a new `destructive` variant for Delete) and a new
  shared SecondaryButton (promoted from AppearanceSettings' inline copy).
  QbzPrimaryButton auto-sizes as a footer chip while staying full-width in login
  (verified: login stretches it in a VerticalLayout; footers take preferred width).
- Kept legitimately-local icon+label controls (grid "+ New" CTA, AddToMixtape
  picker chips, toolbar sort/filter/view-mode pills) but verified every glyph is
  centered.
- Fixed 3 glyph-stuck-left bugs (DiscographyBuilder checkboxes used non-snapped
  centering -> the Math.round(.../1px)*1px crisp idiom).

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

cc
This commit is contained in:
vicrodh committed 2026-06-09 08:29:11 -06:00
1 parent 6cbd51427d
commit 16e6232220
9 files changed
+184 -282

No files matched your search

@@ -21,6 +21,8 @@ import { Radius } from "../foundation/radius.slint";
import { MyQbzAddState, MyQbzAddActions, MyQbzAddRow } from "../state.slint"; import { MyQbzAddState, MyQbzAddActions, MyQbzAddRow } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
import { LineEdit } from "std-widgets.slint"; import { LineEdit } from "std-widgets.slint";
// A single labelled radio option in the create-panel Kind toggle. // A single labelled radio option in the create-panel Kind toggle.
@@ -156,50 +158,45 @@ component AddPickRow inherits Rectangle {
} }
} }
// A small footer button (Cancel / Back / create-buttons). // The icon+label "+ Mixtapes / + Collections" picker-footer chips. Kept as a
component FooterButton inherits Rectangle { // local secondary pill (rule #3 of the button audit: legitimately icon+label,
// so it does NOT collapse to the icon-only CircleAction). The leading icon sits
// in a centered [icon · gap · label] group — never jammed at the left edge.
component FooterIconButton inherits Rectangle {
in property <string> label; in property <string> label;
in property <bool> primary: false;
in property <bool> enabled-look: true;
in property <image> icon; in property <image> icon;
in property <bool> has-icon: false;
callback clicked(); callback clicked();
height: 38px; height: 38px;
border-radius: Radius.sm; border-radius: Radius.sm;
opacity: root.enabled-look ? 1.0 : 0.5; background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
background: root.primary border-width: 1px;
? (ta.has-hover ? #ffffffd6 : Theme.accent)
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
border-width: root.primary ? 0px : 1px;
border-color: Theme.border-subtle; border-color: Theme.border-subtle;
HorizontalLayout { HorizontalLayout {
padding-left: root.primary ? 20px : 16px; padding-left: 16px;
padding-right: root.primary ? 20px : 16px; padding-right: 16px;
spacing: 6px; spacing: 6px;
alignment: center; alignment: center;
if root.has-icon: VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
QbzIcon { QbzIcon {
source: root.icon; source: root.icon;
width: 14px; width: 14px;
height: 14px; height: 14px;
tint: root.primary ? #ffffff : Theme.text-primary; tint: Theme.text-primary;
} }
} }
Text { Text {
text: root.label; text: root.label;
color: root.primary ? #ffffff : Theme.text-primary; color: Theme.text-primary;
font-size: Typography.body; font-size: Typography.body;
font-weight: Typography.semibold; font-weight: Typography.semibold;
vertical-alignment: center; vertical-alignment: center;
} }
} }
ta := TouchArea { ta := TouchArea {
mouse-cursor: root.enabled-look ? pointer : default; mouse-cursor: pointer;
clicked => { clicked => {
if (root.enabled-look) { root.clicked();
root.clicked();
}
} }
} }
} }
@@ -376,10 +373,9 @@ export component AddToMixtapeModal inherits Rectangle {
HorizontalLayout { HorizontalLayout {
padding: 12px; padding: 12px;
spacing: 8px; spacing: 8px;
FooterButton { FooterIconButton {
horizontal-stretch: 1; horizontal-stretch: 1;
label: @tr("Mixtapes"); label: @tr("Mixtapes");
has-icon: true;
icon: @image-url("../assets/icons/plus.svg"); icon: @image-url("../assets/icons/plus.svg");
clicked => { clicked => {
MyQbzAddActions.show-create("mixtape"); MyQbzAddActions.show-create("mixtape");
@@ -387,10 +383,9 @@ export component AddToMixtapeModal inherits Rectangle {
} }
// Hidden for track/playlist payloads (collections hold // Hidden for track/playlist payloads (collections hold
// whole albums only). // whole albums only).
if !MyQbzAddState.restrict-to-mixtape: FooterButton { if !MyQbzAddState.restrict-to-mixtape: FooterIconButton {
horizontal-stretch: 1; horizontal-stretch: 1;
label: @tr("Collections"); label: @tr("Collections");
has-icon: true;
icon: @image-url("../assets/icons/plus.svg"); icon: @image-url("../assets/icons/plus.svg");
clicked => { clicked => {
MyQbzAddActions.show-create("collection"); MyQbzAddActions.show-create("collection");
@@ -460,25 +455,25 @@ export component AddToMixtapeModal inherits Rectangle {
} }
// Spacer pushes the footer down. // Spacer pushes the footer down.
Rectangle { vertical-stretch: 1; } Rectangle { vertical-stretch: 1; }
// Footer: Back + Create & Add. // Footer: Back (secondary) + Create & Add (primary) —
// the app's shared labeled primitives.
HorizontalLayout { HorizontalLayout {
alignment: end; alignment: end;
spacing: 8px; spacing: 8px;
FooterButton { SecondaryButton {
label: @tr("Back"); label: @tr("Back");
enabled-look: !MyQbzAddState.create-busy; height: 38px;
enabled: !MyQbzAddState.create-busy;
clicked => { clicked => {
MyQbzAddActions.create-back(); MyQbzAddActions.create-back();
} }
} }
FooterButton { QbzPrimaryButton {
label: @tr("Create & Add"); label: @tr("Create & Add");
primary: true; height: 38px;
enabled-look: root.can-create; enabled: root.can-create;
clicked => { clicked => {
if (root.can-create) { MyQbzAddActions.create-and-add();
MyQbzAddActions.create-and-add();
}
} }
} }
} }
@@ -14,6 +14,8 @@ import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint"; import { Radius } from "../foundation/radius.slint";
import { MyQbzCreateState, MyQbzCreateActions } from "../state.slint"; import { MyQbzCreateState, MyQbzCreateActions } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
import { LineEdit } from "std-widgets.slint"; import { LineEdit } from "std-widgets.slint";
// A single labelled radio option in the Kind toggle. // A single labelled radio option in the Kind toggle.
@@ -163,55 +165,26 @@ export component CreateMyQbzModal inherits Rectangle {
} }
} }
// Cancel + Create. // Cancel + Create — the app's shared labeled primitives
// (auto-width, 38px footer chips).
HorizontalLayout { HorizontalLayout {
alignment: end; alignment: end;
spacing: 8px; spacing: 8px;
// Cancel (secondary). // Cancel (secondary).
Rectangle { SecondaryButton {
width: cancel-label.preferred-width + 32px; label: @tr("Cancel");
height: 38px; height: 38px;
border-radius: Radius.sm; clicked => {
background: cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; MyQbzCreateActions.close();
border-width: 1px;
border-color: Theme.border-subtle;
cancel-label := Text {
text: @tr("Cancel");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
cancel-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
MyQbzCreateActions.close();
}
} }
} }
// Create (primary) — label reuses the empty-state CTA. // Create (primary) — label reuses the empty-state CTA.
Rectangle { QbzPrimaryButton {
width: create-label.preferred-width + 40px; label: root.is-mixtape ? @tr("+ New Mixtape") : @tr("+ New Collection");
height: 38px; height: 38px;
border-radius: Radius.sm; enabled: root.can-create;
opacity: root.can-create ? 1.0 : 0.5; clicked => {
background: create-ta.has-hover ? #ffffffd6 : Theme.accent; MyQbzCreateActions.submit();
create-label := Text {
text: root.is-mixtape ? @tr("+ New Mixtape") : @tr("+ New Collection");
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
create-ta := TouchArea {
mouse-cursor: root.can-create ? pointer : default;
clicked => {
if (root.can-create) {
MyQbzCreateActions.submit();
}
}
} }
} }
} }
@@ -14,6 +14,8 @@ import { Typography } from "../foundation/typography.slint";
import { DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate } from "../state.slint"; import { DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.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) ────────────────────────────────── // ── Concrete dark-theme values (spec §0) ──────────────────────────────────
// Resolved here as global consts so the whole view shares one source. // Resolved here as global consts so the whole view shares one source.
@@ -314,8 +316,8 @@ component CandidateRow inherits Rectangle {
source: @image-url("../assets/icons/check.svg"); source: @image-url("../assets/icons/check.svg");
width: 11px; width: 11px;
height: 11px; height: 11px;
x: (parent.width - self.width) / 2; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: (parent.height - self.height) / 2; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: D.btn-primary-text; tint: D.btn-primary-text;
} }
TouchArea { TouchArea {
@@ -718,8 +720,8 @@ export component DiscographyBuilderView inherits Rectangle {
source: @image-url("../assets/icons/check.svg"); source: @image-url("../assets/icons/check.svg");
width: 11px; width: 11px;
height: 11px; height: 11px;
x: (parent.width - self.width) / 2; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: (parent.height - self.height) / 2; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: D.btn-primary-text; tint: D.btn-primary-text;
} }
// Indeterminate dash. // Indeterminate dash.
@@ -728,8 +730,8 @@ export component DiscographyBuilderView inherits Rectangle {
width: 9px; width: 9px;
height: 2px; height: 2px;
background: D.accent; background: D.accent;
x: (parent.width - self.width) / 2; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: (parent.height - self.height) / 2; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
} }
TouchArea { TouchArea {
mouse-cursor: pointer; mouse-cursor: pointer;
@@ -819,64 +821,30 @@ export component DiscographyBuilderView inherits Rectangle {
font-size: 13px; font-size: 13px;
} }
} }
// .footer-actions. // .footer-actions — the app's shared labeled primitives.
HorizontalLayout { HorizontalLayout {
spacing: 8px; spacing: 8px;
alignment: end; alignment: end;
// Cancel (secondary-btn). property <bool> create-disabled:
Rectangle { DiscoBuilderState.creating
width: cancel-row.preferred-width + 32px; || DiscoBuilderState.selected-count == 0
|| DiscoBuilderState.collection-name == "";
// Cancel (secondary).
SecondaryButton {
label: @tr("Cancel");
height: 36px; height: 36px;
background: cancel-ta.has-hover ? D.bg-hover : D.bg-secondary; clicked => {
border-radius: 8px; DiscoBuilderActions.back();
border-width: 1px;
border-color: D.bg-tertiary;
cancel-row := HorizontalLayout {
alignment: center;
Text {
text: @tr("Cancel");
color: D.text-primary;
font-size: 13px;
font-weight: 600;
vertical-alignment: center;
}
}
cancel-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoBuilderActions.back();
}
} }
} }
// Create (primary-btn). // Create (primary).
create-btn := Rectangle { QbzPrimaryButton {
property <bool> disabled: label: DiscoBuilderState.creating
DiscoBuilderState.creating ? @tr("Loading...") : @tr("Create Collection");
|| DiscoBuilderState.selected-count == 0
|| DiscoBuilderState.collection-name == "";
width: create-row.preferred-width + 40px;
height: 36px; height: 36px;
background: D.accent; enabled: !parent.create-disabled;
border-radius: 8px; clicked => {
opacity: create-btn.disabled ? 0.45 : 1.0; DiscoBuilderActions.create();
create-row := HorizontalLayout {
alignment: center;
Text {
text: DiscoBuilderState.creating
? @tr("Loading...") : @tr("Create Collection");
color: D.btn-primary-text;
font-size: 13px;
font-weight: 600;
vertical-alignment: center;
}
}
TouchArea {
mouse-cursor: create-btn.disabled ? default : pointer;
clicked => {
if (!create-btn.disabled) {
DiscoBuilderActions.create();
}
}
} }
} }
} }
@@ -25,6 +25,7 @@ import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint"; import { Radius } from "../foundation/radius.slint";
import { MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, TrackItem, NavState } from "../state.slint"; import { MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, TrackItem, NavState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { CircleAction } from "../primitives/CircleAction.slint";
import { TrackRow } from "../primitives/TrackRow.slint"; import { TrackRow } from "../primitives/TrackRow.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint"; import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
@@ -32,46 +33,6 @@ import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint"; import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { CollectionMosaic } from "CollectionMosaic.slint"; import { CollectionMosaic } from "CollectionMosaic.slint";
// --- Hero action button (.action-btn-circle family) ---------------------
// Ghost circle (translucent fill + faint ring) homologated with the album /
// playlist transport rows; `.primary` keeps the same ghost look (the Tauri
// CTA fills accent but our transport row family is ghost everywhere). `enabled`
// dims + disables (item-count == 0).
component ActionCircle inherits Rectangle {
in property <image> icon;
in property <bool> primary: false;
in property <length> diameter: 44px;
in property <bool> enabled: true;
in property <length> glyph: 19px;
callback clicked();
width: root.diameter;
height: root.diameter;
border-radius: root.diameter / 2;
opacity: root.enabled ? 1.0 : 0.4;
background: root.primary
? (ac-ta.has-hover ? #ffffffd6 : #ffffff)
: (ac-ta.has-hover ? Theme.surface-hover : transparent);
border-width: root.primary ? 0px : 1px;
border-color: ac-ta.has-hover ? Theme.text-disabled : Theme.border-subtle;
QbzIcon {
source: root.icon;
width: root.glyph;
height: root.glyph;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.primary ? #000000 : (ac-ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
ac-ta := TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}
// --- One sort / type-filter dropdown row -------------------------------- // --- One sort / type-filter dropdown row --------------------------------
component MenuOption inherits Rectangle { component MenuOption inherits Rectangle {
in property <string> label; in property <string> label;
@@ -874,22 +835,21 @@ export component MixtapeDetailView inherits Rectangle {
HorizontalLayout { HorizontalLayout {
spacing: 8px; spacing: 8px;
alignment: start; alignment: start;
// (1) Play — primary. // (1) Play — primary (filled white disc + black glyph;
ActionCircle { // the shared CircleAction.primary, spec §3.6).
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg"); icon: @image-url("../assets/icons/play-fill.svg");
primary: true; primary: true;
diameter: 44px; diameter: 44px;
glyph: 20px;
enabled: MyQbzDetailState.item-count > 0; enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.play-all(); } clicked => { MyQbzDetailActions.play-all(); }
} }
// (2) Shuffle. // (2) Shuffle.
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
ActionCircle { CircleAction {
icon: @image-url("../assets/icons/shuffle.svg"); icon: @image-url("../assets/icons/shuffle.svg");
diameter: 40px; diameter: 40px;
glyph: 18px;
enabled: MyQbzDetailState.item-count > 0; enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.shuffle(); } clicked => { MyQbzDetailActions.shuffle(); }
} }
@@ -897,10 +857,9 @@ export component MixtapeDetailView inherits Rectangle {
// (3) DJ-mix (DjIcon) — STUB click (modal is a later slice). // (3) DJ-mix (DjIcon) — STUB click (modal is a later slice).
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
ActionCircle { CircleAction {
icon: @image-url("../assets/icons/turntable.svg"); icon: @image-url("../assets/icons/turntable.svg");
diameter: 40px; diameter: 40px;
glyph: 18px;
enabled: MyQbzDetailState.item-count > 0; enabled: MyQbzDetailState.item-count > 0;
clicked => { MyQbzDetailActions.dj-mix(); } clicked => { MyQbzDetailActions.dj-mix(); }
} }
@@ -911,10 +870,9 @@ export component MixtapeDetailView inherits Rectangle {
overflow-anchor := Rectangle { overflow-anchor := Rectangle {
width: 40px; width: 40px;
height: 40px; height: 40px;
ActionCircle { CircleAction {
icon: @image-url("../assets/icons/ellipsis.svg"); icon: @image-url("../assets/icons/ellipsis.svg");
diameter: 40px; diameter: 40px;
glyph: 18px;
clicked => { overflow-menu.show(); } clicked => { overflow-menu.show(); }
} }
overflow-menu := PopupWindow { overflow-menu := PopupWindow {
+20 -50
View File
@@ -16,6 +16,8 @@ import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint"; import { Radius } from "../foundation/radius.slint";
import { MyQbzEditState, MyQbzEditActions } from "../state.slint"; import { MyQbzEditState, MyQbzEditActions } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
import { LineEdit, TextEdit } from "std-widgets.slint"; import { LineEdit, TextEdit } from "std-widgets.slint";
export component MyQbzEditModal inherits Rectangle { export component MyQbzEditModal inherits Rectangle {
@@ -96,67 +98,35 @@ export component MyQbzEditModal inherits Rectangle {
wrap: word-wrap; wrap: word-wrap;
} }
// --- Footer (m-footer) ----------------------------------- // --- Footer (m-footer) — shared labeled primitives -------
HorizontalLayout { HorizontalLayout {
alignment: end; alignment: end;
spacing: 8px; spacing: 8px;
// Cancel (secondary). // Cancel (secondary).
Rectangle { SecondaryButton {
width: cancel-label.preferred-width + 32px; label: @tr("Cancel");
height: 38px; height: 38px;
border-radius: Radius.sm; enabled: !MyQbzEditState.busy;
background: cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; clicked => {
border-width: 1px; MyQbzEditActions.close();
border-color: Theme.border-subtle;
cancel-label := Text {
text: @tr("Cancel");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
cancel-ta := TouchArea {
mouse-cursor: MyQbzEditState.busy ? default : pointer;
clicked => {
if (!MyQbzEditState.busy) {
MyQbzEditActions.close();
}
}
} }
} }
// Submit — primary (rename/description) or destructive // Submit — primary (rename/description) or destructive
// (delete). // (delete) variant of the shared QbzPrimaryButton.
Rectangle { QbzPrimaryButton {
width: submit-label.preferred-width + 40px; label: root.is-delete ? @tr("Delete") : @tr("Save");
height: 38px; height: 38px;
border-radius: Radius.sm; destructive: root.is-delete;
opacity: root.can-submit ? 1.0 : 0.5; enabled: root.can-submit;
background: root.is-delete clicked => {
? (submit-ta.has-hover ? #e57373d9 : #e57373) if (root.is-rename) {
: (submit-ta.has-hover ? #ffffffd6 : Theme.accent); MyQbzEditActions.submit-rename();
submit-label := Text { } else if (root.is-description) {
text: root.is-delete ? @tr("Delete") : @tr("Save"); MyQbzEditActions.submit-description();
color: #ffffff; } else {
font-size: Typography.body; MyQbzEditActions.confirm-delete();
font-weight: Typography.semibold;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
submit-ta := TouchArea {
mouse-cursor: root.can-submit ? pointer : default;
clicked => {
if (root.can-submit) {
if (root.is-rename) {
MyQbzEditActions.submit-rename();
} else if (root.is-description) {
MyQbzEditActions.submit-description();
} else {
MyQbzEditActions.confirm-delete();
}
}
} }
} }
} }
+13 -44
View File
@@ -23,6 +23,8 @@ import { Radius } from "../foundation/radius.slint";
import { MyQbzMixState, MyQbzMixActions } from "../state.slint"; import { MyQbzMixState, MyQbzMixActions } from "../state.slint";
import { QbzSlider } from "../primitives/QbzSlider.slint"; import { QbzSlider } from "../primitives/QbzSlider.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
export component MyQbzMixModal inherits Rectangle { export component MyQbzMixModal inherits Rectangle {
visible: MyQbzMixState.open; visible: MyQbzMixState.open;
@@ -150,61 +152,28 @@ export component MyQbzMixModal inherits Rectangle {
} }
} }
// --- Footer (.tm-actions, right-aligned) ----------------- // --- Footer (.tm-actions, right-aligned) — shared primitives
HorizontalLayout { HorizontalLayout {
alignment: end; alignment: end;
spacing: 12px; spacing: 12px;
// Cancel (secondary). // Cancel (secondary).
Rectangle { SecondaryButton {
width: cancel-label.preferred-width + 32px; label: @tr("Cancel");
height: 38px; height: 38px;
border-radius: Radius.sm; enabled: !MyQbzMixState.busy;
background: cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; clicked => {
border-width: 1px; MyQbzMixActions.close();
border-color: Theme.border-subtle;
cancel-label := Text {
text: @tr("Cancel");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
cancel-ta := TouchArea {
mouse-cursor: MyQbzMixState.busy ? default : pointer;
clicked => {
if (!MyQbzMixState.busy) {
MyQbzMixActions.close();
}
}
} }
} }
// Shuffle / Add to queue (primary) — mixModal.confirm. // Shuffle / Add to queue (primary) — mixModal.confirm.
Rectangle { QbzPrimaryButton {
width: confirm-label.preferred-width + 40px; label: @tr("Add to queue");
height: 38px; height: 38px;
border-radius: Radius.sm; enabled: root.can-shuffle;
opacity: root.can-shuffle ? 1.0 : 0.5; clicked => {
background: confirm-ta.has-hover && root.can-shuffle MyQbzMixActions.shuffle();
? #ffffffd6
: Theme.accent;
confirm-label := Text {
text: @tr("Add to queue");
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
confirm-ta := TouchArea {
mouse-cursor: root.can-shuffle ? pointer : default;
clicked => {
if (root.can-shuffle) {
MyQbzMixActions.shuffle();
}
}
} }
} }
} }
@@ -17,6 +17,9 @@ export component CircleAction inherits Rectangle {
// While the album's first track is fetching, the icon swaps for a spinner. // While the album's first track is fetching, the icon swaps for a spinner.
in property <bool> loading: false; in property <bool> loading: false;
in property <length> diameter: 40px; in property <length> diameter: 40px;
// When false the button dims and the click is gated (e.g. an empty
// collection's hero CTAs). Additive — existing callers default to enabled.
in property <bool> enabled: true;
// Optional hover tooltip (empty = none), using the global TooltipOverlay. // Optional hover tooltip (empty = none), using the global TooltipOverlay.
in property <string> tooltip; in property <string> tooltip;
callback clicked(); callback clicked();
@@ -24,6 +27,7 @@ export component CircleAction inherits Rectangle {
width: root.diameter; width: root.diameter;
height: root.diameter; height: root.diameter;
border-radius: root.diameter / 2; border-radius: root.diameter / 2;
opacity: root.enabled ? 1.0 : 0.4;
// Overlay-matched contrast: solid white for the primary play, semi- // Overlay-matched contrast: solid white for the primary play, semi-
// transparent white fill + bright 1.5px ring otherwise. // transparent white fill + bright 1.5px ring otherwise.
background: root.primary background: root.primary
@@ -52,9 +56,11 @@ export component CircleAction inherits Rectangle {
} }
ta := TouchArea { ta := TouchArea {
mouse-cursor: pointer; mouse-cursor: root.enabled ? pointer : default;
clicked => { clicked => {
root.clicked(); if (root.enabled) {
root.clicked();
}
} }
changed has-hover => { changed has-hover => {
if (self.has-hover && root.tooltip != "") { if (self.has-hover && root.tooltip != "") {
@@ -7,22 +7,35 @@ import { Typography } from "../foundation/typography.slint";
export component QbzPrimaryButton inherits Rectangle { export component QbzPrimaryButton inherits Rectangle {
in property <string> label; in property <string> label;
in property <bool> enabled: true; in property <bool> enabled: true;
// Destructive variant (e.g. the Delete confirm) — a red fill instead of the
// accent. Additive; existing callers default to the accent primary.
in property <bool> destructive: false;
callback clicked(); callback clicked();
height: 48px; height: 48px;
border-radius: Radius.sm; border-radius: Radius.sm;
opacity: root.enabled ? 1.0 : 0.5; opacity: root.enabled ? 1.0 : 0.5;
background: ta.has-hover && root.enabled ? Theme.accent-hover : Theme.accent; background: root.destructive
? (ta.has-hover && root.enabled ? #e57373d9 : #e57373)
: (ta.has-hover && root.enabled ? Theme.accent-hover : Theme.accent);
animate background { duration: 150ms; } animate background { duration: 150ms; }
Text { // A padded layout drives the preferred-width (label + 20px each side) so the
text: root.label; // button auto-sizes as a footer chip inside an `alignment: end` row, while
color: Theme.accent-text; // still stretching full-width when the parent layout stretches it (login).
font-size: Typography.button; HorizontalLayout {
font-weight: Typography.semibold; alignment: center;
horizontal-alignment: center; padding-left: 20px;
vertical-alignment: center; padding-right: 20px;
Text {
text: root.label;
color: Theme.accent-text;
font-size: Typography.button;
font-weight: Typography.semibold;
horizontal-alignment: center;
vertical-alignment: center;
}
} }
ta := TouchArea { ta := TouchArea {
@@ -0,0 +1,50 @@
// Labeled SECONDARY button — the subdued sibling of QbzPrimaryButton, used for
// Cancel / Back actions. Promoted to a shared primitive from the inline copy in
// AppearanceSettings so the My QBZ modals + the Discography Builder footer read
// identically (the button audit, spec 12c). A bordered surface-elevated chip
// with a centered label; `enabled` dims + gates the click.
//
// Default 160px width matches AppearanceSettings; callers that need an
// auto-width chip (the right-aligned modal footers) set `width` explicitly.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { Typography } from "../foundation/typography.slint";
export component SecondaryButton inherits Rectangle {
in property <string> label;
in property <bool> enabled: true;
callback clicked();
height: 34px;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-subtle;
background: ta.has-hover && root.enabled ? Theme.surface-hover : Theme.surface-elevated;
opacity: root.enabled ? 1.0 : 0.4;
// Padded layout → preferred-width = label + 16px each side, so the button
// auto-sizes as a footer chip; AppearanceSettings sets `width: 160px`
// explicitly to keep its original fixed footprint.
HorizontalLayout {
alignment: center;
padding-left: 16px;
padding-right: 16px;
Text {
text: root.label;
color: Theme.text-secondary;
font-size: Typography.body;
font-weight: Typography.medium;
horizontal-alignment: center;
vertical-alignment: center;
}
}
ta := TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}