Files
qbz/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml
T
vicrodh ed7fa588ea fix(qt): elevated controls lift on hover instead of darkening
Buttons that rest on surfaceElevated used surfaceHover for their hover
fill: a 6% white wash meant for rows that rest transparent. Over an
elevated button it lands DARKER than the rest fill — on a modal's dark
ground the Cancel/Save/footer buttons went near-black under the pointer.

QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived
so every palette has it), its ambient half-alpha twin, and the
elevatedFill / elevatedHoverFill pair an ambient-aware control paints.
Every site with the old pairing (65 across modals, toolbars, selects,
circle actions, chips and cards) now hovers to the lift.

cc
2026-09-14 00:49:54 -06:00

686 lines
29 KiB
QML

// AddToMixtapeModal — the app-wide "Add to Mixtape/Collection" picker
// (myqbz/AddToMixtapeModal.slint, 553 lines). A GLOBAL singleton mounted ONCE
// in AppShell.qml (Slint mounts it the same way at AppShell.slint:824) and
// self-gated on its own document's `open` flag, so every caller only has to
// invoke `QbzMyQbzAdd.open(itemsJson)`.
//
// It carries its OWN backdrop and card — NOT the shared modal chrome (:5) —
// and TWO mutually exclusive body panels: the PICKER (search row + collection
// list + the two create chips) and the CREATE-NEW panel (name + kind +
// footer).
//
// Chrome (:209-231): scrim `#00000099` that closes on click, card
// `min(width - 80, 480) x min(height - 120, 560)` centred, r12 surface-card,
// 1px border-subtle, and a click-swallowing area over the card so a press
// inside it never reaches the scrim. The scrim carries `radius:
// theme.radiusMd` for the reason controls/DiscoverConfigModal.qml:59-75 spells
// out: QML `clip` is a rectangular scissor, so an opaque full-bleed child
// paints into the shell frame's four bezel corners and the panel reads square.
// The .slint's `drop-shadow-blur: 32px` is dropped — shadows render nothing on
// this port's software path; the 1px border carries the separation.
//
// The header's title/subtitle are PRECOMPUTED IN RUST (single item ->
// title+subtitle; bulk -> "{count} items" + "{first} + N more"), so this file
// never inspects the pending items.
//
// WHAT MAY GO WHERE (R1) IS ALSO RUST'S ANSWER, not a rule this file re-derives
// — see myqbz_add_qt.rs `payload_accepts`. The picker LIST arrives already
// filtered (a container that cannot take the payload is never a row), and the
// two creatable kinds arrive as `allowMixtape` / `allowCollection`, which gate
// the footer chips AND the create-panel radios. That second half matters: with
// the old single `restrictToMixtape` flag the chips narrowed to one but the
// create panel still showed BOTH radios, so a track payload could still be
// flipped to "Collections" and written into one. Nothing here ever renders an
// action the payload cannot perform, which is the whole point — the backend
// block is a safety net that must stay invisible.
//
// The name typed into the create panel is QML-LOCAL: the bridge takes it as an
// argument (`createAndAdd(kind, name)`) and has no name property. Same for the
// create KIND, which the two radios flip locally over the doc's value — there
// is no `setCreateKind` invokable, and re-calling `showCreate` for a radio tap
// would be a round trip for a value the submit call already carries.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// Guarded parse — a raw JSON.parse in a binding throws on the pre-publish
// frame and takes the whole overlay down (PlaylistView.qml:55-61).
readonly property var doc: {
try {
return JSON.parse(QbzMyQbzAdd.addJson)
} catch (e) {
return ({})
}
}
readonly property bool opened: root.doc.open === true
readonly property bool loading: root.doc.loading === true
readonly property bool creating: root.doc.creating === true
readonly property bool createBusy: root.doc.createBusy === true
// R1, as Rust computed it for the pending payload. Both may be true, one
// may be true; both false cannot reach a visible modal (Rust refuses to
// open the picker when nothing accepts the payload).
readonly property bool allowMixtape: root.doc.allowMixtape === true
readonly property bool allowCollection: root.doc.allowCollection === true
readonly property string busyId: root.doc.busyId || ""
readonly property string search: root.doc.search || ""
readonly property var rows: root.doc.rows || []
// Local create-panel state (see the header).
property string createName: ""
property string createKindOverride: ""
readonly property string docCreateKind: root.doc.createKind || "mixtape"
readonly property string createKind: root.createKindOverride !== ""
? root.createKindOverride : root.docCreateKind
readonly property bool createMixtape: root.createKind === "mixtape"
// R1 for the kind the panel is currently on. The forbidden radio is not
// rendered, so this can only go false on a stale frame — but Create & Add
// must not be pressable in that frame either.
readonly property bool createKindAllowed: root.createKind === "collection"
? root.allowCollection : root.allowMixtape
// :207 — the raw non-empty check, exactly as Slint gates it (Rust trims).
readonly property bool canCreate: root.createName !== "" && !root.createBusy
&& root.createKindAllowed
// A fresh panel starts empty and follows the kind Rust was asked for; a
// closed modal must not keep a half-typed name for the next caller.
onDocCreateKindChanged: root.createKindOverride = ""
onCreatingChanged: if (root.creating) root.createName = ""
onOpenedChanged: if (!root.opened) { root.createName = ""; root.createKindOverride = "" }
visible: root.opened
enabled: root.opened
// --- Scrim. Declared FIRST so the card (later in source order, hence
// above it) keeps its own presses.
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#99000000"
MouseArea {
anchors.fill: parent
onClicked: QbzMyQbzAdd.close()
}
}
Rectangle {
id: card
width: Math.min(root.width - 80, 480)
height: Math.min(root.height - 120, 560)
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
clip: true
// Swallow clicks so they never reach the scrim.
MouseArea { anchors.fill: parent }
// ======================= HEADER (always) ======================
Item {
id: header
width: parent.width
// :235 — the row's own height plus 20 top + 16 bottom.
height: Math.max(hdrText.implicitHeight, 28) + 36
Column {
id: hdrText
x: 20
y: 20
width: Math.max(0, card.width - 40 - 28)
spacing: 2
Text {
text: root.t("ADD TO MIXTAPE/COLLECTION")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Text {
width: parent.width
text: root.doc.headerTitle || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Text {
visible: (root.doc.headerSubtitle || "") !== ""
width: parent.width
text: root.doc.headerSubtitle || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Item {
x: card.width - 20 - 28
y: 20
width: 28
height: 28
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 17
height: 17
tintName: hdrCloseArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: hdrCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzMyQbzAdd.close()
}
}
// Bottom border on the block's LAST pixel (:285-289).
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
}
// ======================== PICKER PANEL ========================
Item {
id: picker
visible: !root.creating
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
// --- Search row (:295-361). The leading glyph sits OUTSIDE the
// box, which is unique to this modal — QbzLineEdit puts its
// magnifier inside — so the box is inlined here rather than
// forking the shared control.
Item {
id: searchRow
width: parent.width
height: 46
QbzIcon {
x: 20
anchors.verticalCenter: searchBox.verticalCenter
name: "search"
width: 14
height: 14
tintName: "muted"
}
Rectangle {
id: searchBox
x: 20 + 14 + 8
width: Math.max(0, parent.width - x - 20)
height: 43
y: Math.round((parent.height - height) / 2)
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: searchInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
text: root.search
onTextEdited: QbzMyQbzAdd.search(text)
// A republish re-seeds the field while it is not being
// edited (QbzLineEdit.qml:167-172).
Binding {
target: searchInput
property: "text"
value: root.search
when: !searchInput.activeFocus
}
}
Text {
visible: searchInput.text === ""
anchors.fill: searchInput
text: root.t("Search…")
color: theme.textMuted
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
Rectangle {
y: parent.height - 1
width: parent.width
height: 1
color: theme.borderSubtle
}
}
// --- Footer: the two create chips (:403-432). 60px, elevated, a
// 1px rule at its TOP (:406-409 puts it at y=0), padding 12.
Rectangle {
id: pickerFooter
anchors.bottom: parent.bottom
width: parent.width
height: 60
color: theme.surfaceElevated
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
Row {
anchors.fill: parent
anchors.margins: 12
spacing: 8
// Both chips carry `horizontal-stretch: 1`, so one fills
// the row and two split it. R1 decides how many there are:
// one chip per CREATABLE container, never zero (a payload
// nothing accepts never opened this modal). The guard on
// `chips > 0` is only so a pre-publish frame cannot divide
// by zero.
readonly property int chips:
(root.allowMixtape ? 1 : 0) + (root.allowCollection ? 1 : 0)
readonly property real chipW:
chips > 0 ? (width - (chips - 1) * 8) / chips : 0
FooterIconButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.allowMixtape
width: visible ? parent.chipW : 0
label: root.t("Mixtapes")
onClicked: QbzMyQbzAdd.showCreate("mixtape")
}
FooterIconButton {
// Offered only when a Collection can take the payload:
// albums / singles / EPs, from any source. Tracks and
// playlists have no Collection slot (R1).
anchors.verticalCenter: parent.verticalCenter
visible: root.allowCollection
width: visible ? parent.chipW : 0
label: root.t("Collections")
onClicked: QbzMyQbzAdd.showCreate("collection")
}
}
}
// --- Between the two: spinner / empty band / the list.
Item {
anchors.top: searchRow.bottom
anchors.bottom: pickerFooter.top
width: parent.width
// Loading (:364-368): centred, 24px padding, 30px spinner.
QbzSpinner {
visible: root.loading
anchors.horizontalCenter: parent.horizontalCenter
y: 24
size: 30
}
// Empty (:370-381).
Text {
visible: !root.loading && root.rows.length === 0
width: parent.width
height: 80
text: root.search !== "" ? root.t("No matches.")
: root.t("No mixtapes or collections yet.")
color: theme.textMuted
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
// List (:384-400).
ListView {
id: pickList
visible: !root.loading && root.rows.length > 0
// The .slint's `padding: 8` on the list layout, on all
// FOUR sides. The horizontal half is an ANCHOR margin on
// the view, never an `x` on the delegate: a vertical
// ListView writes its delegates' `x`, so a literal `x: 8`
// there is silently clobbered to 0 and the rows sat flush
// against the card's left border with a 16px gap opening
// on the right (the hover fill and the zebra band touched
// the border on one side only). The vertical half stays a
// Flickable content margin.
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
topMargin: 8
bottomMargin: 8
spacing: 2
cacheBuffer: 200
reuseItems: true
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.rows
delegate: AddPickRow {
required property var modelData
required property int index
width: pickList.width
rowData: modelData
rowIndex: index
}
}
}
}
// ====================== CREATE-NEW PANEL ======================
Item {
visible: root.creating
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
Column {
id: createTop
x: 20
y: 20
width: Math.max(0, parent.width - 40)
spacing: 16
// --- NAME
Column {
width: parent.width
spacing: 8
Text {
text: root.t("NAME")
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Rectangle {
width: parent.width
height: 43
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: nameInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
enabled: !root.createBusy
color: theme.textPrimary
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
// LOAD-BEARING, and the exact shape `searchInput`
// above uses (:242 + its Binding). With ONLY the
// focus-gated Binding below, a re-entered create
// panel kept the previously typed name on screen
// while `createName` had already been reset to ""
// — the field never re-seeds because it keeps
// activeFocus across the hide/show (the panel is
// only `visible: false` for a frame, focus comes
// straight back), so the `when:` guard never
// fires. `canCreate` was then false with a name
// visibly in the box: "Create & Add" sat at 50 %
// with a dead MouseArea and Enter did nothing.
// Repros: chip -> type -> Back -> chip; and
// type -> close -> reopen into the create panel.
// A declarative `text:` survives user editing (the
// edit does not break the binding), and
// `onTextEdited` writes the same value straight
// back, so typing is unaffected.
text: root.createName
onTextEdited: root.createName = text
onAccepted: {
if (root.canCreate)
QbzMyQbzAdd.createAndAdd(root.createKind, root.createName)
}
Binding {
target: nameInput
property: "text"
value: root.createName
when: !nameInput.activeFocus
}
}
Text {
visible: nameInput.text === ""
anchors.fill: nameInput
// NOT translated — verbatim literals in the .slint
// (:482).
text: root.createMixtape ? "90s Cassettes" : "Jazz Library"
color: theme.textMuted
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
// --- KIND (16px between the radios here; the Create modal
// uses 12 — CreateMyQbzModal.slint:150).
Column {
width: parent.width
spacing: 8
Text {
text: root.t("KIND")
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
// R1 again, and this is the half the old flag missed: a
// radio for a kind the payload cannot go into is NOT
// rendered. A Row skips invisible children, so the single
// remaining (and already selected) radio simply reads as
// the kind being created.
Row {
spacing: 16
QbzRadioOption {
visible: root.allowMixtape
label: root.t("Mixtapes")
selected: root.createMixtape
onClicked: if (!root.createBusy) root.createKindOverride = "mixtape"
}
QbzRadioOption {
visible: root.allowCollection
label: root.t("Collections")
selected: !root.createMixtape
onClicked: if (!root.createBusy) root.createKindOverride = "collection"
}
}
}
}
// Footer, pushed to the bottom by the .slint's vertical stretch
// spacer (:526).
Row {
anchors.right: parent.right
anchors.rightMargin: 20
anchors.bottom: parent.bottom
anchors.bottomMargin: 20
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: root.t("Back")
btnHeight: 38
minWidth: 0
enabled: !root.createBusy
onClicked: QbzMyQbzAdd.createBack()
}
QbzPrimaryButton {
anchors.verticalCenter: parent.verticalCenter
label: root.t("Create & Add")
btnHeight: 38
btnEnabled: root.canCreate
onClicked: QbzMyQbzAdd.createAndAdd(root.createKind, root.createName)
}
}
}
}
// One collection row in the picker list (:63-158). 52px, r8, zebra on ODD
// indices, dimmed and inert while ANOTHER row is being written to.
component AddPickRow: Rectangle {
id: pickRow
// NOT `data`: Item already owns a `data` default property.
property var rowData: ({})
property int rowIndex: 0
readonly property bool busy: root.busyId !== ""
height: 52
radius: 8
opacity: pickRow.busy ? 0.5 : 1.0
// TrackRow.qml:200-206's exact pair: the hover is the polarity-baked
// alpha ramp (so it is visible on light themes too) with the
// pre-publish fallback, and the zebra stays the dark literal.
color: (pickHover.containsMouse && !pickRow.busy)
? (theme.alphaTiers.length > 0 ? theme.alphaTier(8)
: (theme.isDark ? "#14ffffff" : "#14000000"))
: (pickRow.rowIndex % 2 === 1 ? "#07ffffff" : "transparent")
MouseArea {
id: pickHover
anchors.fill: parent
hoverEnabled: true
cursorShape: pickRow.busy ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: if (!pickRow.busy) QbzMyQbzAdd.pick(pickRow.rowData.id)
}
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 12
Item {
anchors.verticalCenter: parent.verticalCenter
width: 32
height: 32
QbzIcon {
anchors.centerIn: parent
// Rust resolves the name (myqbz_add.rs:203-209).
name: pickRow.rowData.icon === "user" ? "user"
: pickRow.rowData.icon === "library-big" ? "library-big"
: "cassette-tape"
width: 18
height: 18
tintName: "muted"
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 32 - 12 - kindTag.width - 12)
Text {
width: parent.width
text: pickRow.rowData.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Row {
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: pickRow.rowData.meta || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
// "Already added" — the collection already holds EVERY
// pending item (Rust's item_exists pass).
Row {
visible: pickRow.rowData.alreadyHas === true
anchors.verticalCenter: parent.verticalCenter
spacing: 3
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: "circle-check-big"
width: 12
height: 12
tintName: "accent"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.t("Already added")
color: theme.accent
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
}
}
}
}
Text {
id: kindTag
anchors.verticalCenter: parent.verticalCenter
text: pickRow.rowData.kindLabel || ""
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 1.2
}
}
}
// The "+ Mixtapes" / "+ Collections" footer chips (:164-201): a 38px
// labelled icon button with a CENTRED [glyph · gap · label] group. Neither
// SettingsButton (34px, 160 min width) nor QbzToolButton (30px) is this
// shape, and both call sites are in this file, so it stays local.
component FooterIconButton: Rectangle {
id: chip
property string label: ""
signal clicked()
height: 38
radius: theme.radiusSm
color: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.centerIn: parent
spacing: 6
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: "plus"
width: 14
height: 14
tintName: "textPrimary"
}
Text {
anchors.verticalCenter: parent.verticalCenter
// The leading `+` is the GLYPH above, not part of the msgid.
text: chip.label
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: chipArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: chip.clicked()
}
}
}