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
601 lines
24 KiB
QML
601 lines
24 KiB
QML
// PurchasesToolbar — the per-tab control row of the Purchases list. 1:1 port of
|
||
// purchases/PurchasesView.slint's `AlbumsToolbar` (:915-1050) and
|
||
// `TracksToolbar` (:1055-1120), folded into one file because they are the same
|
||
// bar with a different set of controls.
|
||
//
|
||
// THE TWO BARS ARE NOT THE SAME, and that is the whole point of the file:
|
||
//
|
||
// Albums [stretch] group · sort · filter · grid/list
|
||
// Tracks [stretch] group · filter
|
||
//
|
||
// No sort and no grid/list on Tracks (§5, `PurchasesView.svelte:574` vs `:727`).
|
||
// Building the Albums bar on both tabs invents two controls the reference never
|
||
// had, so the arms are separate `visible` blocks rather than one parameterised
|
||
// row that quietly grows a control.
|
||
//
|
||
// Everything is right-aligned (the .slint opens each bar with a stretch
|
||
// spacer). Controls are 30px on a 8px gap — the toolbar scale of
|
||
// ui-control-alignment-standard, the same one LibraryToolbar's chips use.
|
||
//
|
||
// The dropdowns are `QbzContextMenu`, not bespoke anchored Rectangles: it is
|
||
// this port's shared menu surface, it clamps itself into the window, it closes
|
||
// on an outside press, and it carries the ADR-009 stacking the .slint has to
|
||
// spell out by hand (`z: 3000` on its filter panel).
|
||
|
||
import QtQuick
|
||
import com.blitzfc.qbz
|
||
import "../../controls"
|
||
import "../../theme"
|
||
|
||
Item {
|
||
id: root
|
||
property bool kioskHost: false
|
||
|
||
/// "albums" | "tracks".
|
||
property string tab: "albums"
|
||
/// `list_json.toolbar` (§G.2).
|
||
property var toolbar: ({})
|
||
|
||
QbzTheme { id: theme }
|
||
|
||
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||
|
||
readonly property string group: root.toolbar.group || "off"
|
||
readonly property string sortKey: root.toolbar.sort || "purchased"
|
||
readonly property bool ascending: root.toolbar.ascending === true
|
||
readonly property string viewMode: root.toolbar.viewMode || "grid"
|
||
readonly property bool hideUnavailable: root.toolbar.hideUnavailable === true
|
||
readonly property bool hideDownloaded: root.toolbar.hideDownloaded === true
|
||
readonly property string qualityFilter: root.toolbar.qualityFilter || "all"
|
||
readonly property var genres: root.toolbar.genres || []
|
||
readonly property string genreFilter: root.toolbar.genreFilter || ""
|
||
|
||
/// The funnel's badge. Counted GLOBALLY — the Tracks bar hides the
|
||
/// Availability section but the preference itself is still in force, which
|
||
/// is exactly what the .slint's single `active-filter-count` does.
|
||
readonly property int activeFilterCount:
|
||
(root.hideUnavailable ? 1 : 0)
|
||
+ (root.hideDownloaded ? 1 : 0)
|
||
+ (root.qualityFilter !== "all" ? 1 : 0)
|
||
+ (root.genreFilter !== "" && root.tab !== "tracks" ? 1 : 0)
|
||
|
||
/// The same three settings as the badge, but SAID — the applied-filters
|
||
/// tooltip. Counted the same way and in the menu's own order, so the number
|
||
/// on the disc and the lines in the bubble can never disagree.
|
||
readonly property var filterSummaryGroups: {
|
||
var avail = []
|
||
if (root.hideUnavailable)
|
||
avail.push(root.t("Hide unavailable"))
|
||
if (root.hideDownloaded)
|
||
avail.push(root.t("Hide downloaded"))
|
||
var q = []
|
||
if (root.qualityFilter === "hires")
|
||
q.push(root.t("Hi-Res"))
|
||
else if (root.qualityFilter === "cd")
|
||
q.push(root.t("CD"))
|
||
else if (root.qualityFilter === "lossy")
|
||
q.push(root.t("Lossy"))
|
||
var g = root.genreFilter !== "" && root.tab !== "tracks" ? [root.genreFilter] : []
|
||
return [
|
||
{ group: root.t("Availability"), values: avail },
|
||
{ group: root.t("Quality"), values: q },
|
||
{ group: root.t("Genre"), values: g }
|
||
]
|
||
}
|
||
|
||
width: parent ? parent.width : 0
|
||
height: root.kioskHost ? 64 : 30
|
||
|
||
// ── One toolbar dropdown trigger (.slint `ToolbarButton`, :37-70) ──────
|
||
component ToolbarButton: Rectangle {
|
||
id: tb
|
||
property bool kioskHost: false
|
||
property string label: ""
|
||
property bool active: false
|
||
signal clicked()
|
||
width: tb.kioskHost ? Math.max(64, tbRow.width) : tbRow.width
|
||
height: tb.kioskHost ? 64 : 30
|
||
radius: 6
|
||
color: tbArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||
Row {
|
||
id: tbRow
|
||
height: parent.height
|
||
leftPadding: 10
|
||
rightPadding: 8
|
||
spacing: 6
|
||
Text {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: tb.label
|
||
color: tb.active ? theme.accent : theme.textPrimary
|
||
font.pixelSize: 12
|
||
}
|
||
QbzIcon {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
name: "chevron-down"
|
||
width: 14
|
||
height: 14
|
||
tintName: "muted"
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: tbArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: tb.clicked()
|
||
}
|
||
}
|
||
|
||
// ── One row inside an open dropdown (.slint `MenuRow`, :73-104) ────────
|
||
component MenuRow: Rectangle {
|
||
id: mr
|
||
property string label: ""
|
||
property bool selected: false
|
||
/// "↑" / "↓" on the active sort row, "" everywhere else. A bare glyph,
|
||
/// not a translatable string — the .slint uses the same two literals.
|
||
property string arrow: ""
|
||
signal picked()
|
||
width: parent ? parent.width : 0
|
||
height: 32
|
||
radius: 4
|
||
color: mrArea.containsMouse ? theme.surfaceHover : "transparent"
|
||
Row {
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 12
|
||
anchors.rightMargin: 12
|
||
spacing: 8
|
||
Text {
|
||
width: Math.max(0, parent.width - (mr.arrow !== "" ? arrowText.width + 8 : 0))
|
||
height: parent.height
|
||
text: mr.label
|
||
color: mr.selected ? theme.accent : theme.textSecondary
|
||
font.pixelSize: 12
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
id: arrowText
|
||
visible: mr.arrow !== ""
|
||
height: parent.height
|
||
text: mr.arrow
|
||
color: theme.accent
|
||
font.pixelSize: 12
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: mrArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: mr.picked()
|
||
}
|
||
}
|
||
|
||
// ── Section label inside the filter panel ─────────────────────────────
|
||
component FilterSectionLabel: Text {
|
||
color: theme.textMuted
|
||
font.pixelSize: theme.fontLegal
|
||
font.weight: theme.weightSemibold
|
||
}
|
||
|
||
// ── One checkbox + label line inside the filter panel ─────────────────
|
||
component FilterCheckRow: Item {
|
||
id: fcr
|
||
property string label: ""
|
||
property bool checked: false
|
||
signal toggled()
|
||
width: parent ? parent.width : 0
|
||
height: 24
|
||
Row {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 8
|
||
QbzCheckbox {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
checked: fcr.checked
|
||
onToggled: fcr.toggled()
|
||
}
|
||
Text {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: fcr.label
|
||
color: theme.textPrimary
|
||
font.pixelSize: theme.fontBody
|
||
}
|
||
}
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: fcr.toggled()
|
||
}
|
||
}
|
||
|
||
// ── One Quality radio (.slint `QualityRadio`, :168-222) ───────────────
|
||
component QualityRadioRow: Item {
|
||
id: qr
|
||
property string label: ""
|
||
property string hint: ""
|
||
property bool selected: false
|
||
signal picked()
|
||
width: parent ? parent.width : 0
|
||
height: 26
|
||
Row {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 8
|
||
Rectangle {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: 16
|
||
height: 16
|
||
radius: 8
|
||
border.width: qr.selected ? 0 : 1.5
|
||
border.color: theme.textMuted
|
||
color: qr.selected ? theme.accent : "transparent"
|
||
Rectangle {
|
||
visible: qr.selected
|
||
anchors.centerIn: parent
|
||
width: 6
|
||
height: 6
|
||
radius: 3
|
||
// The .slint says `Theme.accent-text`; this port routes
|
||
// every on-accent mark through the measured selector
|
||
// (theme/QbzTheme.qml, "ON AN ACCENT FILL"), exactly like
|
||
// controls/QbzRadioOption.qml.
|
||
color: theme.accentGlyphColor
|
||
}
|
||
}
|
||
Text {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: qr.label
|
||
color: theme.textPrimary
|
||
font.pixelSize: theme.fontBody
|
||
}
|
||
Text {
|
||
visible: qr.hint !== ""
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: qr.hint
|
||
color: theme.textMuted
|
||
font.pixelSize: theme.fontLegal
|
||
}
|
||
}
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: qr.picked()
|
||
}
|
||
}
|
||
|
||
// ── The bar ───────────────────────────────────────────────────────────
|
||
Row {
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 8
|
||
|
||
// --- Group ---------------------------------------------------------
|
||
ToolbarButton {
|
||
id: groupBtn
|
||
kioskHost: root.kioskHost
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
label: root.tab === "tracks"
|
||
? (root.group === "album" ? root.t("Group: Album")
|
||
: root.group === "artist" ? root.t("Group: Artist")
|
||
: root.group === "name" ? root.t("Group: Name")
|
||
: root.t("Group: Off"))
|
||
: (root.group === "artist" ? root.t("Group: Artist")
|
||
: root.group === "alpha" ? root.t("Group: A–Z")
|
||
: root.t("Group: Off"))
|
||
onClicked: groupMenu.openBelowLeft(groupBtn)
|
||
|
||
QbzContextMenu {
|
||
id: groupMenu
|
||
menuWidth: 180
|
||
MenuRow {
|
||
label: root.t("Off")
|
||
selected: root.group === "off"
|
||
onPicked: { QbzPurchases.setGroup("off"); groupMenu.close() }
|
||
}
|
||
// Albums arm: A–Z / Artist. `visible: false` drops the row AND
|
||
// its spacing — the panel's Column skips it entirely, so the
|
||
// two arms never leave a gap behind.
|
||
MenuRow {
|
||
visible: root.tab !== "tracks"
|
||
label: root.t("A–Z")
|
||
selected: root.group === "alpha"
|
||
onPicked: { QbzPurchases.setGroup("alpha"); groupMenu.close() }
|
||
}
|
||
// Tracks arm: Album / Artist / Name.
|
||
MenuRow {
|
||
visible: root.tab === "tracks"
|
||
label: root.t("Album")
|
||
selected: root.group === "album"
|
||
onPicked: { QbzPurchases.setGroup("album"); groupMenu.close() }
|
||
}
|
||
MenuRow {
|
||
label: root.t("Artist")
|
||
selected: root.group === "artist"
|
||
onPicked: { QbzPurchases.setGroup("artist"); groupMenu.close() }
|
||
}
|
||
MenuRow {
|
||
visible: root.tab === "tracks"
|
||
label: root.t("Name")
|
||
selected: root.group === "name"
|
||
onPicked: { QbzPurchases.setGroup("name"); groupMenu.close() }
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Sort (ALBUMS ONLY) --------------------------------------------
|
||
ToolbarButton {
|
||
id: sortBtn
|
||
kioskHost: root.kioskHost
|
||
// A Row skips an invisible child AND its spacing, so the Tracks bar
|
||
// closes up on its own — no width dance is needed here.
|
||
visible: root.tab !== "tracks"
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
label: root.sortKey === "artist" ? root.t("Sort: Artist")
|
||
: root.sortKey === "album" ? root.t("Sort: Album")
|
||
: root.sortKey === "quality" ? root.t("Sort: Quality")
|
||
: root.sortKey === "released" ? root.t("Sort: Release date")
|
||
: root.t("Sort: Date")
|
||
onClicked: sortMenu.openBelowLeft(sortBtn)
|
||
|
||
// Re-picking the ACTIVE key flips the direction, which is the
|
||
// .slint's `select-album-sort` (:790-800) and Tauri's behaviour:
|
||
// there is no separate direction control on this screen.
|
||
function pick(key) {
|
||
if (root.sortKey === key)
|
||
QbzPurchases.setSort(key, !root.ascending)
|
||
else
|
||
QbzPurchases.setSort(key, false)
|
||
sortMenu.close()
|
||
}
|
||
function arrowFor(key) {
|
||
return root.sortKey === key ? (root.ascending ? "↑" : "↓") : ""
|
||
}
|
||
|
||
QbzContextMenu {
|
||
id: sortMenu
|
||
menuWidth: 180
|
||
// "purchased" is §G.2's key for the purchase date; the label is
|
||
// the existing "Sort: Date" / "Date" msgid pair.
|
||
MenuRow {
|
||
label: root.t("Date")
|
||
selected: root.sortKey === "purchased"
|
||
arrow: sortBtn.arrowFor("purchased")
|
||
onPicked: sortBtn.pick("purchased")
|
||
}
|
||
MenuRow {
|
||
label: root.t("Release date")
|
||
selected: root.sortKey === "released"
|
||
arrow: sortBtn.arrowFor("released")
|
||
onPicked: sortBtn.pick("released")
|
||
}
|
||
MenuRow {
|
||
label: root.t("Artist")
|
||
selected: root.sortKey === "artist"
|
||
arrow: sortBtn.arrowFor("artist")
|
||
onPicked: sortBtn.pick("artist")
|
||
}
|
||
MenuRow {
|
||
label: root.t("Album")
|
||
selected: root.sortKey === "album"
|
||
arrow: sortBtn.arrowFor("album")
|
||
onPicked: sortBtn.pick("album")
|
||
}
|
||
MenuRow {
|
||
label: root.t("Quality")
|
||
selected: root.sortKey === "quality"
|
||
arrow: sortBtn.arrowFor("quality")
|
||
onPicked: sortBtn.pick("quality")
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Filter funnel + panel ------------------------------------------
|
||
Rectangle {
|
||
id: filterBtn
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: root.kioskHost ? 64 : 30
|
||
height: root.kioskHost ? 64 : 30
|
||
radius: 6
|
||
color: filterArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||
QbzIcon {
|
||
name: "list-filter"
|
||
width: 16
|
||
height: 16
|
||
anchors.centerIn: parent
|
||
tintName: root.activeFilterCount > 0 ? "accent" : "muted"
|
||
}
|
||
// Active-count badge — a 14px accent disc in the corner
|
||
// (.slint:124-140). Declared after the glyph so it paints over it.
|
||
Rectangle {
|
||
visible: root.activeFilterCount > 0
|
||
x: parent.width - width - 1
|
||
y: 1
|
||
width: 14
|
||
height: 14
|
||
radius: 7
|
||
color: theme.accent
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: root.activeFilterCount
|
||
color: theme.accentGlyphColor
|
||
font.pixelSize: 9
|
||
font.weight: theme.weightSemibold
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: filterArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
purchasesFilterTip.exit()
|
||
filterMenu.openBelowRight(filterBtn)
|
||
}
|
||
onEntered: purchasesFilterTip.enter()
|
||
onExited: purchasesFilterTip.exit()
|
||
}
|
||
|
||
QbzFilterTip {
|
||
id: purchasesFilterTip
|
||
ownerKey: "purchases-filter"
|
||
anchor: filterBtn
|
||
groups: root.filterSummaryGroups
|
||
}
|
||
|
||
QbzContextMenu {
|
||
id: filterMenu
|
||
menuWidth: 280
|
||
contentSpacing: 10
|
||
|
||
// Header + "Clear all".
|
||
Item {
|
||
width: parent ? parent.width : 0
|
||
height: 22
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 4
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: root.t("Filters")
|
||
color: theme.textPrimary
|
||
font.pixelSize: theme.fontBody
|
||
font.weight: theme.weightSemibold
|
||
}
|
||
// §G.1 has no `clear_all_filters` invokable, so "Clear all"
|
||
// is the three writes it stands for. Same end state, no new
|
||
// seam.
|
||
Text {
|
||
id: clearAll
|
||
visible: root.activeFilterCount > 0
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 4
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: root.t("Clear all")
|
||
color: theme.accent
|
||
font.pixelSize: theme.fontLegal
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
QbzPurchases.setHideUnavailable(false)
|
||
QbzPurchases.setHideDownloaded(false)
|
||
QbzPurchases.setQualityFilter("all")
|
||
QbzPurchases.setGenreFilter("")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Availability — ALBUMS ONLY (.slint:1090 `show-availability`).
|
||
Column {
|
||
visible: root.tab !== "tracks"
|
||
width: parent ? parent.width : 0
|
||
spacing: 6
|
||
FilterSectionLabel { text: root.t("Availability") }
|
||
FilterCheckRow {
|
||
label: root.t("Hide unavailable")
|
||
checked: root.hideUnavailable
|
||
onToggled: QbzPurchases.setHideUnavailable(!root.hideUnavailable)
|
||
}
|
||
}
|
||
|
||
// Quality.
|
||
Column {
|
||
width: parent ? parent.width : 0
|
||
spacing: 4
|
||
FilterSectionLabel { text: root.t("Quality") }
|
||
QualityRadioRow {
|
||
label: root.t("All")
|
||
selected: root.qualityFilter === "all"
|
||
onPicked: QbzPurchases.setQualityFilter("all")
|
||
}
|
||
QualityRadioRow {
|
||
label: root.t("Hi-Res")
|
||
// Bare units, not a sentence — untranslated in the
|
||
// .slint too (:1082).
|
||
hint: "24bit+"
|
||
selected: root.qualityFilter === "hires"
|
||
onPicked: QbzPurchases.setQualityFilter("hires")
|
||
}
|
||
QualityRadioRow {
|
||
label: root.t("CD Quality")
|
||
hint: "16/44.1"
|
||
selected: root.qualityFilter === "cd"
|
||
onPicked: QbzPurchases.setQualityFilter("cd")
|
||
}
|
||
QualityRadioRow {
|
||
label: root.t("MP3")
|
||
hint: "MP3"
|
||
// "lossy", NOT "mp3": that is the value the shared
|
||
// filter predicate names (`QualityFilter::Lossy`).
|
||
selected: root.qualityFilter === "lossy"
|
||
onPicked: QbzPurchases.setQualityFilter("lossy")
|
||
}
|
||
}
|
||
|
||
// Genre — ALBUMS ONLY, from the catalog's own genre on each
|
||
// purchased album (the tracks page carries none). Absent
|
||
// entirely while the listing has no genres to offer.
|
||
Column {
|
||
visible: root.tab !== "tracks" && root.genres.length > 0
|
||
width: parent ? parent.width : 0
|
||
spacing: 4
|
||
FilterSectionLabel { text: root.t("Genre") }
|
||
QualityRadioRow {
|
||
label: root.t("All genres")
|
||
selected: root.genreFilter === ""
|
||
onPicked: QbzPurchases.setGenreFilter("")
|
||
}
|
||
Repeater {
|
||
model: root.genres
|
||
delegate: QualityRadioRow {
|
||
required property var modelData
|
||
label: modelData
|
||
selected: root.genreFilter === modelData
|
||
onPicked: QbzPurchases.setGenreFilter(modelData)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Downloads.
|
||
Column {
|
||
width: parent ? parent.width : 0
|
||
spacing: 6
|
||
FilterSectionLabel { text: root.t("Downloads") }
|
||
FilterCheckRow {
|
||
label: root.t("Hide downloaded")
|
||
checked: root.hideDownloaded
|
||
onToggled: QbzPurchases.setHideDownloaded(!root.hideDownloaded)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Grid / list (ALBUMS ONLY) --------------------------------------
|
||
// The glyph shows the OTHER mode, like Tauri and the .slint (:145-165).
|
||
Rectangle {
|
||
id: viewBtn
|
||
visible: root.tab !== "tracks"
|
||
width: root.kioskHost ? 64 : 30
|
||
height: root.kioskHost ? 64 : 30
|
||
radius: 6
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
color: viewArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated
|
||
QbzIcon {
|
||
name: root.viewMode === "grid" ? "list" : "layout-grid"
|
||
width: 16
|
||
height: 16
|
||
anchors.centerIn: parent
|
||
tintName: viewArea.containsMouse ? "textPrimary" : "muted"
|
||
}
|
||
MouseArea {
|
||
id: viewArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: QbzPurchases.setViewMode(
|
||
root.viewMode === "grid" ? "list" : "grid")
|
||
}
|
||
}
|
||
}
|
||
}
|