Files
qbz/crates/qbz-qt/qml/views/purchases/PurchasesToolbar.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

601 lines
24 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
}
}
}
}