fix(qbz-qt): local context menus, collapsing search fields, toolbar density

Four things the owner hit in Local Library.

The context menu did nothing there — local rows and cards now mount the shared
menu the rest of the app uses, with the action set Slint gives local and Plex
items. Anything with no seam in this port (add-to-playlist, add-to-mixtape, the
tag editor, local favourites) is hidden or visibly disabled rather than a row
that silently does nothing.

The per-view search fields did not open and collapse the way the header's does.
That behaviour moved into the shared QbzLineEdit, so the Local toolbar, the
queue and the tree rail all get it from one place instead of four copies. The
queue's field also escaped its 300px column; it is width-bound now.

Filter, sorting and grouping become icon-only buttons with an explanatory
tooltip, which is what the owner asked for and what the crowding called for.

Found on the way, and affecting Qobuz cards too: the album card's ⋯ menu had no
"Play later" at all, and its "Add to queue" row dispatched play-later — the row
lied about what it did. Both fixed, with Slint's icon set for the three.

cc
This commit is contained in:
vicrodh committed 2026-07-28 16:23:23 -06:00
1 parent c63889b487
commit e0fcf7a089
15 files changed
+767 -202

No files matched your search

+2
View File
@@ -97,6 +97,8 @@ fn main() {
"qml/views/PlaylistView.qml",
"qml/views/SearchView.qml",
"qml/views/SectionRail.qml",
"qml/views/local/LocalTip.qml",
"qml/views/local/LocalIconSelect.qml",
"qml/shell/FavToggle.qml",
"qml/shell/InfoCreditCell.qml",
"qml/shell/InfoMetaCell.qml",
+6 -3
View File
@@ -216,7 +216,8 @@ Rectangle {
var m = [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-plus", "action": "next" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
if (!root.localMode) {
@@ -264,13 +265,15 @@ Rectangle {
if (a === "open") root.openRequested()
else if (a === "play") root.playRequested()
else if (a === "next") root.enqueueRequested("next")
else if (a === "queue") root.enqueueRequested("later")
else if (a === "later") root.enqueueRequested("later")
else if (a === "queue") root.enqueueRequested("queue")
return
}
if (a === "open") QbzAlbum.openAlbum(root.albumId)
else if (a === "play") QbzPlayer.playAlbum(root.albumId)
else if (a === "next") QbzPlayer.enqueueAlbum(root.albumId, "next")
else if (a === "queue") QbzPlayer.enqueueAlbum(root.albumId, "later")
else if (a === "later") QbzPlayer.enqueueAlbum(root.albumId, "later")
else if (a === "queue") QbzPlayer.enqueueAlbum(root.albumId, "queue")
else if (a === "favorite") root.toggleFavorite()
// "block" (no blacklist store): inert —
// POC-NOTE.
+180 -23
View File
@@ -1,58 +1,215 @@
// QbzLineEdit (std-widgets LineEdit, settings styling) — extracted from
// SettingsView.qml in phase 19. 240px / 34px elevated r8 bordered input;
// QbzLineEdit — THE shared text input. Two arms over ONE control, so there
// is never a second search-box implementation in the tree:
//
// * PLAIN (default, unchanged): 240x34 elevated r8 bordered input that
// commits on Enter AND on focus loss (the Tauri onchange semantics —
// PlaybackSettings.slint).
// PlaybackSettings.slint). Settings panels use this arm.
//
// * SEARCH (`searchMode`): leading magnifier, LIVE `edited(text)` and a
// trailing X. With `expandable` it is primitives/ExpandableSearch.slint
// 1:1 — a 34px (30px when `sm`) magnifier slot that, on click, opens a
// RIGHT-ANCHORED field growing LEFT over whatever sits beside it (200ms
// ease-in-out), the magnifier cross-fading out (120ms); focus is grabbed
// one tick later because focusing synchronously inside the open races the
// open animation (the Slint comment at ExpandableSearch.slint:26); the X
// clears AND closes; Esc closes. The closed footprint is fixed, so
// nothing to the control's right ever moves.
//
// Every per-view search box (Local Library toolbars, tree rail, folder
// detail, queue) is this control.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
// --- plain arm (API unchanged) ---------------------------------------
property string text: ""
property string placeholder: ""
property bool isPassword: false
signal committed(string value)
// --- search arm -------------------------------------------------------
/// Leading magnifier + live `edited` + trailing X.
property bool searchMode: false
/// Collapse to the magnifier slot (implies searchMode semantics).
property bool expandable: false
/// Bootstrap-style small variant (ExpandableSearch `sm`): 30px, not 34px.
property bool sm: false
/// Open width of the expandable field (ExpandableSearch max-open-width).
property int openWidth: 196
/// surface-elevated fill (toolbar variant) vs surface-card (rail/overlay).
property bool elevated: true
property bool open: false
signal edited(string value)
QbzTheme { id: theme }
width: 240
height: 34
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
color: theme.surfaceElevated
readonly property int collapsedSize: sm ? 30 : 34
readonly property int glyph: sm ? 13 : 14
// The ROOT keeps the closed footprint; only the inner field animates, so
// a positioner never reflows while the search opens.
width: expandable ? collapsedSize : 240
height: (searchMode || expandable) ? collapsedSize : 34
color: "transparent"
function clearSearch() {
input.text = ""
root.text = ""
root.edited("")
}
function closeSearch() {
clearSearch()
root.open = false
input.focus = false
}
// Deferred focus — see the header note.
onOpenChanged: if (open) focusDefer.restart()
Timer {
id: focusDefer
interval: 30
repeat: false
onTriggered: input.forceActiveFocus()
}
// --- Closed: the magnifier toggle (fades, so both directions are smooth)
Rectangle {
visible: root.expandable
x: root.width - width
width: root.collapsedSize
height: root.collapsedSize
radius: 6
opacity: root.open ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: 120 } }
color: openArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "search"
width: root.sm ? 14 : 16
height: root.sm ? 14 : 16
anchors.centerIn: parent
tintName: openArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: openArea
anchors.fill: parent
enabled: !root.open
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.open = true
}
}
// --- The field. Right-anchored, so an expandable one grows LEFT. ------
Rectangle {
id: box
x: root.width - width
width: root.expandable ? (root.open ? root.openWidth : 0) : root.width
height: root.height
radius: (root.searchMode || root.expandable) ? 6 : theme.radiusSm
color: root.elevated ? theme.surfaceElevated : theme.surfaceCard
border.width: (root.expandable && !root.open) ? 0 : 1
border.color: input.activeFocus ? theme.accent : theme.borderSubtle
clip: true
Behavior on width {
enabled: root.expandable
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
}
Row {
anchors.fill: parent
anchors.leftMargin: root.searchMode ? (root.expandable ? 11 : 9) : 12
anchors.rightMargin: root.searchMode ? 5 : 12
spacing: root.searchMode ? 7 : 0
QbzIcon {
visible: root.searchMode
name: "search"
width: root.glyph
height: root.glyph
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
Item {
// Clamped: the open animation passes through width 0 and a
// negative width is a silent layout trap.
width: Math.max(0, parent.width
- (root.searchMode ? root.glyph + 7 : 0)
- (clearSlot.visible ? clearSlot.width + 7 : 0))
height: parent.height
clip: true
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.pixelSize: root.searchMode ? 12 : theme.fontBody
verticalAlignment: Text.AlignVCenter
clip: true
echoMode: parent.isPassword ? TextInput.Password : TextInput.Normal
text: parent.text
onAccepted: parent.committed(text)
onActiveFocusChanged: if (!activeFocus) parent.committed(text)
// External republish (e.g. Reset) re-seeds the field while it is
// not being edited.
selectByMouse: true
echoMode: root.isPassword ? TextInput.Password : TextInput.Normal
text: root.text
onAccepted: root.committed(text)
onActiveFocusChanged: if (!activeFocus) root.committed(text)
onTextEdited: root.edited(text)
Keys.onEscapePressed: function (event) {
if (root.expandable) {
root.closeSearch()
event.accepted = true
} else {
event.accepted = false
}
}
// External republish (e.g. Reset) re-seeds the field while
// it is not being edited.
Binding {
target: input
property: "text"
value: input.parent.text
value: root.text
when: !input.activeFocus
}
}
Text {
visible: input.text === ""
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
text: placeholder
text: root.placeholder
color: theme.textMuted
font.pixelSize: theme.fontBody
font.pixelSize: root.searchMode ? 12 : theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
// Trailing X — clears (plain search) or clears AND closes
// (expandable, back to the magnifier).
Item {
id: clearSlot
visible: root.searchMode && (root.expandable || input.text !== "")
width: visible ? (root.sm ? 22 : 24) : 0
height: parent.height
Rectangle {
anchors.centerIn: parent
width: root.sm ? 22 : 24
height: root.sm ? 22 : 24
radius: 4
color: clearArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "x"
width: 12
height: 12
anchors.centerIn: parent
tintName: clearArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.expandable ? root.closeSearch() : root.clearSearch()
}
}
}
}
}
}
+24 -35
View File
@@ -631,6 +631,7 @@ Rectangle {
font.pixelSize: 11
}
Row {
id: footerRow
width: parent.width
leftPadding: theme.spacingMd
rightPadding: theme.spacingMd
@@ -638,6 +639,12 @@ Rectangle {
bottomPadding: 10
spacing: theme.spacingXs
// The row's usable width — `parent.width` here is the PANEL
// width, padding included; using it raw is what pushed the
// search field out of the 300px column.
readonly property int contentWidth:
width - leftPadding - rightPadding
// Action 1 — Clear queue (wired).
QbzIconButton { btnSize: 30
name: "trash-list"
@@ -651,42 +658,24 @@ Rectangle {
// Action 4 — sleep timer (INERT engine — POC-NOTE).
QbzIconButton { btnSize: 30; name: "clock" }
// Inline queue search field (wired).
Rectangle {
width: parent.width - 4 * 30 - 4 * theme.spacingXs
height: 30
radius: theme.radiusSm
color: theme.surfaceElevated
Row {
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 7
spacing: 6
QbzIcon {
name: "search"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
TextInput {
width: parent.width - 20
height: parent.height
color: theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: QbzQueue.queueSetSearch(text)
Text {
visible: parent.text === ""
anchors.fill: parent
text: QbzSession.tr("Search queue", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
}
// Filler so the collapsed magnifier sits at the right edge;
// the field opens LEFT across it (5 gaps of spacingXs).
Item {
width: Math.max(0, footerRow.contentWidth - 4 * 30 - 30
- 5 * theme.spacingXs)
height: 1
}
// Inline queue search (wired) — the shared expandable control.
QbzLineEdit {
searchMode: true
expandable: true
sm: true
// Bound to the PANEL, never wider: it opens leftward over
// the four action buttons, exactly like the Slint
// ExpandableSearch does over its neighbours.
openWidth: Math.max(90, footerRow.contentWidth)
placeholder: QbzSession.tr("Search queue", QbzSession.trRev)
onEdited: function (v) { QbzQueue.queueSetSearch(v) }
}
}
}
@@ -3,8 +3,12 @@
// border on hover), square cover = card width - 16 with a folder placeholder,
// name 13px/medium, recursive track count 11px/muted. Click drills in.
// Right-click opens the same folder queue menu the tree rows carry (a
// deliberate divergence — the Slint card has no menu; see TreeRow.qml).
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Rectangle {
@@ -66,7 +70,29 @@ Rectangle {
id: cardArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: root.opened()
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
folderMenu.openAtCursor(cardArea, mouse.x, mouse.y)
return
}
root.opened()
}
}
CardMenu {
id: folderMenu
menuWidth: 200
entries: [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "play") QbzLocal.playFolder(root.item.path)
else QbzLocal.enqueue("folder", root.item.path, a)
}
}
}
@@ -19,6 +19,7 @@
import QtQuick
import com.blitzfc.qbz
import "../../cards"
import "../../controls"
import "../../theme"
Item {
@@ -162,6 +163,30 @@ Item {
required property var modelData
width: 200
height: 246
// Right-click menu for the card (AlbumCard.slint's
// album-menu, local arm: no favourite, no Block —
// the Slint hides Block for source local/plex).
// The card's own ⋯ overlay button opens AlbumCard's
// built-in menu; see the GLUE note about aligning
// that one's entries with these.
CardMenu {
id: cardMenu
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "open") root.openRequested(cardCell.modelData.id)
else if (a === "play") root.playRequested(cardCell.modelData.id)
else root.enqueueRequested(cardCell.modelData.id, a)
}
}
AlbumCard {
localMode: true
albumId: cardCell.modelData.id
@@ -182,6 +207,16 @@ Item {
root.enqueueRequested(cardCell.modelData.id, m)
}
}
// RIGHT-only, declared after the card so every
// left click still reaches the card's own areas.
MouseArea {
id: cardRc
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: function (mouse) {
cardMenu.openAtCursor(cardRc, mouse.x, mouse.y)
}
}
// Multi-select tick — the collection view's
// select-mode overlay (top-left of the artwork).
Rectangle {
@@ -216,6 +251,9 @@ Item {
checked: root.selected[modelData.items[0].id] === true
onOpened: root.openRequested(modelData.items[0].id)
onPlayRequested: root.playRequested(modelData.items[0].id)
onEnqueueRequested: function (m) {
root.enqueueRequested(modelData.items[0].id, m)
}
onToggleSelect: root.toggleSelect(modelData.items[0].id)
}
}
@@ -2,9 +2,16 @@
// surfaces (AlbumCollectionView's list mode, mounted by
// LocalLibraryView.slint:1255 with show-source / show-source-badge on).
//
// 56px: cover, title + artist, year, track count, quality mark and the
// source column. Multi-select puts the shared checkbox in front of the
// cover, as the collection view does.
// 56px: cover, title + artist, year, track count, quality mark, the source
// column and the trailing ⋯ overflow. Multi-select puts the shared checkbox
// in front of the cover, as the collection view does.
//
// The ⋯ menu is AlbumListRow.slint:381 minus its two Qobuz-only entries: the
// favourite heart (local albums are not catalog favourites) and "Block this
// album" (which the Slint itself hides for source local/plex, :435). What is
// left is the five entries Slint always shows — Open album, Play, Play next,
// Play later, Add to queue — and all five are wired. Right-clicking the row
// opens the same menu at the pointer.
import QtQuick
import com.blitzfc.qbz
@@ -21,6 +28,7 @@ Rectangle {
property bool checked: false
signal opened()
signal playRequested()
signal enqueueRequested(string mode)
signal toggleSelect()
QbzTheme { id: theme }
@@ -33,11 +41,36 @@ Rectangle {
id: rowArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: root.selectMode ? root.toggleSelect() : root.opened()
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
return
}
if (root.selectMode) root.toggleSelect()
else root.opened()
}
onDoubleClicked: if (!root.selectMode) root.playRequested()
}
CardMenu {
id: rowMenu
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "open") root.opened()
else if (a === "play") root.playRequested()
else root.enqueueRequested(a)
}
}
Row {
anchors.fill: parent
anchors.leftMargin: 10
@@ -68,8 +101,9 @@ Rectangle {
}
}
Column {
width: parent.width - 40 - 70 - 90 - 92 - (root.showSource ? 34 : 0)
- (root.selectMode ? 28 : 0) - 5 * 12
width: parent.width - 40 - 70 - 90 - 92 - 32
- (root.showSource ? 34 : 0)
- (root.selectMode ? 28 : 0) - 6 * 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
@@ -125,5 +159,27 @@ Rectangle {
tintName: root.item.source === "plex" ? "accent" : "muted"
}
}
// Trailing ⋯ overflow (AlbumListRow.slint:360).
Rectangle {
width: 32
height: 32
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
name: "ellipsis"
width: 18
height: 18
anchors.centerIn: parent
tintName: moreArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: moreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
}
}
}
}
@@ -62,7 +62,9 @@ Item {
onClicked: QbzLocal.playFolder(root.view.selectedFolder)
}
Column {
width: parent.width - 40 - 200 - 60 - 3 * 12
// The search collapses to a 30px magnifier and opens LEFT
// over this column, so the name block owns that space.
width: parent.width - 40 - 30 - 60 - 3 * 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
@@ -85,8 +87,6 @@ Item {
LocalSearchBox {
anchors.verticalCenter: parent.verticalCenter
boxWidth: 200
iconSize: 14
leftPad: 10
placeholder: QbzSession.tr("Search albums...", QbzSession.trRev)
onEdited: function (v) { root.view.folderDetailSearch = v }
}
@@ -197,8 +197,30 @@ Item {
id: subArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: root.view.selectFolder(subRow.modelData.path)
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
subMenu.openAtCursor(subArea, mouse.x, mouse.y)
return
}
root.view.selectFolder(subRow.modelData.path)
}
}
// Same folder queue menu as the tree rows.
CardMenu {
id: subMenu
menuWidth: 200
entries: [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "play") QbzLocal.playFolder(subRow.modelData.path)
else QbzLocal.enqueue("folder", subRow.modelData.path, a)
}
}
}
}
@@ -0,0 +1,118 @@
// Icon-only dropdown for the Local Library toolbars: a 34x30 bordered button
// — the SAME chrome as the quality/format filter button beside it
// (LocalLibraryView.slint:857) — that opens its option list as the shared
// controls/QbzContextMenu.qml, with a check on the current option and a hover
// tooltip naming the control AND its current value.
//
// DELIBERATE DIVERGENCE from the reference: LocalLibraryView.slint spends this
// space on full-text QbzSelects (sort :793 / :952 / :1033, group :824 / :988 /
// :1063). The owner asked for icon-only + tooltip because the toolbar row was
// too crowded. The option lists, their order, the persisted keys and the
// wiring are unchanged — only the trigger's presentation is.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
id: root
property string iconName: ""
/// What the control IS ("Sort", "Group by") — the tooltip's lead.
property string label: ""
/// Plain label strings, in the same order the host maps to its keys.
property var options: []
property int currentIndex: 0
property int menuWidth: 190
/// Accent chrome while the control is off its default option (the
/// filter button's own "active" cue).
property bool marked: false
signal selected(int index)
QbzTheme { id: theme }
width: 34
height: 30
Rectangle {
anchors.fill: parent
radius: 6
border.width: 1
border.color: root.marked ? theme.accent : theme.borderSubtle
color: btnArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
QbzIcon {
name: root.iconName
width: 15
height: 15
anchors.centerIn: parent
tintName: root.marked ? "accent" : "secondary"
}
MouseArea {
id: btnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: menu.openBelowRight(root)
}
}
LocalTip {
visible: btnArea.containsMouse && !menu.visible
text: root.label
+ (root.currentIndex >= 0 && root.currentIndex < root.options.length
? ": " + root.options[root.currentIndex] : "")
}
QbzContextMenu {
id: menu
menuWidth: root.menuWidth
Repeater {
model: root.options
delegate: Rectangle {
id: optRow
required property var modelData
required property int index
width: parent ? parent.width : 0
height: 33
radius: 5
color: optArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
QbzIcon {
// "" renders nothing — the slot keeps the labels aligned.
name: optRow.index === root.currentIndex ? "check" : ""
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
Text {
height: parent.height
width: parent.width - 23
text: optRow.modelData
color: optRow.index === root.currentIndex
? theme.accent : theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: optArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
menu.close()
root.selected(optRow.index)
}
}
}
}
}
}
@@ -1,78 +1,28 @@
// Local Library search input — the ONE box the whole surface uses.
// Local Library search input — a NAMING wrapper over the shared
// controls/QbzLineEdit.qml search arm, kept only so the six local call sites
// read as one thing and tune in one place.
//
// Slint has three near-identical inputs on this view (LocalLibraryView.slint:
// the toolbar ExpandableSearch `sm`, the tree-rail header input at :1724 and
// the folder-detail input at :1984). They differ only in width, left padding
// and glyph size, so this is one component with those three as properties
// instead of three copies.
//
// Numbers: 30px tall, radius 6, surface-card fill, 1px border that turns
// accent on focus, 12px text, muted placeholder (Slint :1726-1771).
// the toolbar `ExpandableSearch { sm: true }` at :782, the tree-rail header
// input at :1724 and the folder-detail input at :1984). All three are the
// EXPANDABLE form here: closed they are a 30px magnifier slot; clicking opens
// a `boxWidth` field that grows LEFT over its neighbours, and the X clears and
// closes it again (primitives/ExpandableSearch.slint).
import QtQuick
import com.blitzfc.qbz
import "../../theme"
import "../../controls"
Rectangle {
QbzLineEdit {
id: root
property string placeholder: ""
property alias text: input.text
/// Width of the OPEN field (the closed footprint is always 30px).
property int boxWidth: 200
property int iconSize: 13
property int leftPad: 9
property int rightPad: 8
/// Elevated fill for the toolbar variant; the rail/detail inputs use
/// surface-card (Slint :1728).
property bool elevated: false
signal edited(string value)
QbzTheme { id: theme }
width: boxWidth
height: 30
radius: 6
color: elevated ? theme.surfaceElevated : theme.surfaceCard
border.width: 1
border.color: input.activeFocus ? theme.accent : theme.borderSubtle
clip: true
Row {
anchors.fill: parent
anchors.leftMargin: root.leftPad
anchors.rightMargin: root.rightPad
spacing: 6
QbzIcon {
name: "search"
width: root.iconSize
height: root.iconSize
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
Item {
width: parent.width - root.iconSize - 6
height: parent.height
clip: true
TextInput {
id: input
anchors.fill: parent
color: theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
onTextEdited: root.edited(text)
}
Text {
visible: input.text === ""
anchors.fill: parent
text: root.placeholder
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
searchMode: true
expandable: true
sm: true
// surface-card — ExpandableSearch.slint's own overlay fill (the port's
// toolbar copy used surface-elevated; the reference does not).
elevated: false
openWidth: boxWidth
}
@@ -0,0 +1,46 @@
// Hover tooltip for the Local Library's icon-only toolbar controls.
//
// Slint routes these through a shell-level overlay (TooltipState, set by
// LocalLibraryView.slint's CircleIconBtn / RailBulkBtn). This port has no
// tooltip overlay singleton, so the pattern is the one the quality badge
// already uses: a QtQuick.Controls ToolTip (a POPUP, so it survives the
// `clip: true` containers these toolbars live in) styled to the Slint
// numbers — 24px tall, radius sm, surface-elevated on a 1px border-subtle,
// 11px medium secondary text, 9px side padding, 6px above its anchor.
//
// `delay` is 0 and the caller drives `visible` from its own hover area (the
// quality-badge convention), so the tip tracks hover exactly.
import QtQuick
import QtQuick.Controls
import "../../theme"
ToolTip {
id: tip
delay: 0
timeout: -1
padding: 0
implicitHeight: 24
x: parent ? Math.round((parent.width - width) / 2) : 0
y: -height - 6
contentItem: Text {
text: tip.text
color: theme.textSecondary
font.pixelSize: 11
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
leftPadding: 9
rightPadding: 9
}
background: Rectangle {
color: theme.surfaceElevated
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
// The theme instance lives here because a ToolTip's default property
// is its content; ids are component-scoped, so contentItem sees it.
QbzTheme { id: theme }
}
}
+79 -54
View File
@@ -8,6 +8,14 @@
// tab has rows OR a live search; the Folders flat-only block hides in tree
// mode while the Flat/Tree toggle stays; Artists shows the search only when
// there are artists.
//
// PRESENTATION DIVERGENCE (owner request — the row was too crowded):
// * search -> the EXPANDABLE form (Slint already uses ExpandableSearch
// here; the port used to mount an always-open 200px box);
// * sort -> LocalIconSelect "arrow-up-down" (Slint: text QbzSelect)
// * grouping -> LocalIconSelect "layers" (Slint: text QbzSelect)
// * filter -> unchanged icon-only button (matches Slint) + a tooltip.
// Every option list, key mapping and callback is untouched.
import QtQuick
import com.blitzfc.qbz
@@ -38,6 +46,29 @@ Row {
QbzSession.tr("Group by letter", QbzSession.trRev),
QbzSession.tr("Group by artist", QbzSession.trRev),
]
// Tooltip leads for the icon-only controls.
readonly property string sortTip: QbzSession.tr("Sort", QbzSession.trRev)
readonly property string groupTip: QbzSession.tr("Grouping", QbzSession.trRev)
readonly property var tracksSortIds: ["default", "artist-asc", "artist-desc",
"title-asc", "title-desc", "year-desc", "year-asc", "added-desc"]
readonly property var tracksSortLabels: [
QbzSession.tr("Default", QbzSession.trRev),
QbzSession.tr("Artist A-Z", QbzSession.trRev),
QbzSession.tr("Artist Z-A", QbzSession.trRev),
QbzSession.tr("Title A-Z", QbzSession.trRev),
QbzSession.tr("Title Z-A", QbzSession.trRev),
QbzSession.tr("Year (newest)", QbzSession.trRev),
QbzSession.tr("Year (oldest)", QbzSession.trRev),
QbzSession.tr("Date added", QbzSession.trRev),
]
readonly property var tracksGroupIds: ["off", "album", "artist", "name"]
readonly property var tracksGroupLabels: [
QbzSession.tr("No grouping", QbzSession.trRev),
QbzSession.tr("By album", QbzSession.trRev),
QbzSession.tr("By artist", QbzSession.trRev),
QbzSession.tr("By name", QbzSession.trRev),
]
height: 30
spacing: 8
@@ -50,29 +81,33 @@ Row {
height: parent.height
LocalSearchBox {
elevated: true
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search", QbzSession.trRev)
onEdited: function (v) { root.view.albumsSearch = v }
}
QbzSelect {
menuWidth: 180
height: 30
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "arrow-up-down"
label: root.sortTip
menuWidth: 190
options: root.sortLabels
currentIndex: Math.max(0, root.sortIds.indexOf(root.view.albumsSort))
onSelected: function (i) { root.view.albumsSort = root.sortIds[i] }
}
QbzSelect {
menuWidth: 160
height: 30
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "layers"
label: root.groupTip
menuWidth: 180
options: root.groupLabels
marked: root.view.albumsGroup !== "off"
currentIndex: Math.max(0, root.groupIds.indexOf(root.view.albumsGroup))
onSelected: function (i) { root.view.albumsGroup = root.groupIds[i] }
}
// Album identity ("Albums by") — what one album IS. A QUERY change
// (the grouping happens in SQL), so it reloads the set; persisted,
// shared with the Slint app.
// shared with the Slint app. Kept as a LABELLED select: it changes
// what the tab shows, not how it is ordered, and no icon says that.
QbzSelect {
menuWidth: 190
height: 30
@@ -112,6 +147,13 @@ Row {
onClicked: root.view.filterOpen = !root.view.filterOpen
}
}
LocalTip {
visible: filtArea.containsMouse
text: root.view.filterCount > 0
? QbzSession.tr("Filters", QbzSession.trRev)
+ " (" + root.view.filterCount + ")"
: QbzSession.tr("Quality, format and source filters", QbzSession.trRev)
}
Rectangle {
visible: root.view.filterCount > 0
x: parent.width - width + 3
@@ -156,7 +198,7 @@ Row {
onClicked: root.view.toggleTracksMultiSelect()
}
LocalSearchBox {
elevated: true
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search", QbzSession.trRev)
onEdited: function (v) {
root.view.tracksSearch = v
@@ -169,44 +211,24 @@ Row {
onTriggered: QbzLocal.tracksSearch(root.view.tracksSearch)
}
}
QbzSelect {
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "arrow-up-down"
label: root.sortTip
menuWidth: 190
options: root.tracksSortLabels
currentIndex: Math.max(0, root.tracksSortIds.indexOf(QbzLocal.localTracksSort))
onSelected: function (i) { QbzLocal.tracksSetSort(root.tracksSortIds[i]) }
}
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "layers"
label: root.groupTip
menuWidth: 180
height: 30
anchors.verticalCenter: parent.verticalCenter
options: [
QbzSession.tr("Default", QbzSession.trRev),
QbzSession.tr("Artist A-Z", QbzSession.trRev),
QbzSession.tr("Artist Z-A", QbzSession.trRev),
QbzSession.tr("Title A-Z", QbzSession.trRev),
QbzSession.tr("Title Z-A", QbzSession.trRev),
QbzSession.tr("Year (newest)", QbzSession.trRev),
QbzSession.tr("Year (oldest)", QbzSession.trRev),
QbzSession.tr("Date added", QbzSession.trRev),
]
currentIndex: Math.max(0, ["default", "artist-asc", "artist-desc",
"title-asc", "title-desc", "year-desc", "year-asc",
"added-desc"].indexOf(QbzLocal.localTracksSort))
onSelected: function (i) {
QbzLocal.tracksSetSort(["default", "artist-asc", "artist-desc",
"title-asc", "title-desc", "year-desc", "year-asc",
"added-desc"][i])
}
}
QbzSelect {
menuWidth: 160
height: 30
anchors.verticalCenter: parent.verticalCenter
options: [
QbzSession.tr("No grouping", QbzSession.trRev),
QbzSession.tr("By album", QbzSession.trRev),
QbzSession.tr("By artist", QbzSession.trRev),
QbzSession.tr("By name", QbzSession.trRev),
]
currentIndex: Math.max(0, ["off", "album", "artist", "name"]
.indexOf(root.view.tracksGroup))
onSelected: function (i) {
root.view.tracksGroup = ["off", "album", "artist", "name"][i]
}
options: root.tracksGroupLabels
marked: root.view.tracksGroup !== "off"
currentIndex: Math.max(0, root.tracksGroupIds.indexOf(root.view.tracksGroup))
onSelected: function (i) { root.view.tracksGroup = root.tracksGroupIds[i] }
}
}
@@ -224,23 +246,26 @@ Row {
spacing: 8
height: parent.height
LocalSearchBox {
elevated: true
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search", QbzSession.trRev)
onEdited: function (v) { root.view.foldersSearch = v }
}
QbzSelect {
menuWidth: 150
height: 30
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "arrow-up-down"
label: root.sortTip
menuWidth: 170
options: root.sortLabels
currentIndex: Math.max(0, root.sortIds.indexOf(root.view.foldersSort))
onSelected: function (i) { root.view.foldersSort = root.sortIds[i] }
}
QbzSelect {
menuWidth: 150
height: 30
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter
iconName: "layers"
label: root.groupTip
menuWidth: 180
options: root.groupLabels
marked: root.view.foldersGroup !== "off"
currentIndex: Math.max(0, root.groupIds.indexOf(root.view.foldersGroup))
onSelected: function (i) { root.view.foldersGroup = root.groupIds[i] }
}
@@ -269,7 +294,7 @@ Row {
LocalSearchBox {
visible: root.view && root.view.activeTab === "artists"
&& root.view.artists.length > 0
elevated: true
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search artists", QbzSession.trRev)
onEdited: function (v) { root.view.artistsSearch = v }
}
+103 -5
View File
@@ -1,12 +1,35 @@
// Local track row — the SHARED rows/TrackRow.qml with the local arms set,
// plus the two things the shared row has no arm for:
// plus the three things the shared row has no arm for:
//
// * the always-visible SOURCE glyph the Slint renders from
// `show-source: true` (LocalLibraryView.slint:1489) — drawn OUTSIDE the
// shared row so TrackRow keeps one column layout for every surface;
// * the multi-select checkbox (Slint TrackRow's `multi-select-mode`
// swaps the number cell for it) — overlaid on the number cell here for
// the same reason: no fork of the shared row, no shared-file edit.
// the same reason: no fork of the shared row, no shared-file edit;
// * the LOCAL context menu.
//
// THE MENU (this is the `force-local-menu` arm of the unified Slint menu —
// TrackRow.slint:68 -> TrackMenuState -> AppShell.slint:1009-1021 ->
// TrackContextMenu.slint). For a local / Plex / offline row on a library
// surface Slint resolves to: qobuz-actions OFF, favorite OFF, Track info OFF
// (show-track-info rides the Qobuz block), mixtape ON, add-to-playlist ON,
// Go to album / Go to artist ON (`local-goto-actions`). So the full local set
// is: Play now, Play next, Play later, Add to queue, Add to mixtape, Add to
// playlist, Go to album, Go to artist.
//
// Add to mixtape and Add to playlist are OMITTED here, not disabled: this
// port has no Mixtape/Collection store and no playlist picker (the Rust side
// says so itself — `local_album_actions.rs` keeps them as LOGGED SEAMS), and
// a menu row that silently does nothing is worse than an absent one. Every
// row that IS shown is wired.
//
// The shared row's own ⋯ is turned OFF (`showMenu: false`) because its
// entries are the Qobuz set: its Go-to-album/artist call QbzAlbum.openAlbum /
// QbzArtist.openArtist with CATALOG ids, and a local row carries a local
// group key + a bare artist NAME. The button is re-drawn here, in the exact
// same gutter (the shared row is narrowed by precisely the 32px cell + 14px
// gap it no longer draws), and both it and a right-click open this menu.
//
// Artwork follows the Slint gate (AppearanceState.local-library-track-
// artwork, default OFF) — decoding a cover per row is exactly what froze
@@ -14,6 +37,7 @@
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../rows"
import "../../theme"
@@ -35,17 +59,24 @@ Item {
height: 50
// Local rows have a local album GROUP KEY and a bare artist NAME; both
// routes are name/key routes, so each entry is gated on its own datum.
readonly property bool canGoAlbum: (item.albumId || "") !== ""
readonly property bool canGoArtist: (item.artist || "") !== ""
TrackRow {
id: sharedRow
width: root.width - 26
// 26px source-glyph gutter + the 46px (32 cell + 14 gap) the shared
// row would have spent on its own ⋯ — so every other column lands
// exactly where it did before the menu moved out.
width: root.width - 26 - 46
item: root.item
number: root.number
showArtwork: root.showArtwork
showAlbum: root.showAlbum
showFavorite: false
showDownload: false
menuShowGoTo: false
menuShowFavorite: false
showMenu: false
// Local rows are NOT drag sources: item.id is a local DB row id and
// the sidebar drop handler forwards it as a Qobuz catalog id.
draggable: false
@@ -70,6 +101,32 @@ Item {
}
}
// ⋯ menu button — the shared row's own gutter, re-drawn with the local
// action set behind it.
Rectangle {
id: moreBtn
x: root.width - 70
width: 32
height: 32
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
name: "ellipsis"
width: 16
height: 16
anchors.centerIn: parent
tintName: moreArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: moreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
}
}
// Source glyph (local / offline cache / Plex).
QbzIcon {
visible: root.item.source === "local" || root.item.source === "plex"
@@ -81,4 +138,45 @@ Item {
anchors.verticalCenter: parent.verticalCenter
tintName: root.item.source === "plex" ? "accent" : "muted"
}
// Right-click anywhere on the row opens the SAME menu at the pointer
// (TrackRow.slint's `open-track-menu(..., at-pointer: true)`). Declared
// last so it sits on top, and RIGHT-only so every left click still falls
// through to the row body / checkbox / ⋯ underneath.
MouseArea {
id: rcArea
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: function (mouse) { rowMenu.openAtCursor(rcArea, mouse.x, mouse.y) }
}
CardMenu {
id: rowMenu
menuWidth: 220
entries: {
var m = [
{ "label": QbzSession.tr("Play now", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
if (root.canGoAlbum) m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc-3", "action": "go-album" })
if (root.canGoArtist) m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
return m
}
onPicked: function (a) {
if (a === "play") root.playRequested()
else if (a === "go-album") {
QbzLocal.openAlbum(root.item.albumId)
QbzShell.navigateTo("localalbum")
} else if (a === "go-artist") {
// Local/Plex artists have no catalog id — a NAME route into
// the Local Library Artists tab (local_album_actions.rs).
QbzLocal.openArtistByName(root.item.artist)
QbzShell.navigateTo("local")
} else {
root.enqueueRequested(a)
}
}
}
}
@@ -53,10 +53,14 @@ Item {
iconSize: 15
onClicked: QbzLocal.ephemeralOpen()
}
// Filler: the search collapses to a 30px magnifier, so the slot
// stays right-aligned and the field opens LEFT over this gap.
Item {
width: Math.max(0, parent.width - 3 * 30 - 30 - 4 * 4)
height: 1
}
LocalSearchBox {
boxWidth: parent.width - 3 * 34
leftPad: 8
rightPad: 6
placeholder: QbzSession.tr("Search folders", QbzSession.trRev)
onEdited: function (v) {
root.view.treeSearch = v
+32 -1
View File
@@ -6,8 +6,17 @@
// music glyph and the segment name. The selection cue is the 3px accent bar
// on the left edge — ADR-008: never a pill.
// Right-click opens a folder queue menu. DIVERGENCE: LocalLibraryView.slint
// gives the tree rail no per-row menu (only the detail pane's Play button and
// the bulk bar), but the owner asked for working context menus on the local
// rows and every entry here rides a seam that already exists
// (QbzLocal.playFolder / enqueue("folder", path, mode) — local_playback.rs
// `list_folder_tracks_recursive`). TRACK leaves get no menu: their `path` is
// a file path, not a track id, so no track arm accepts it.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Rectangle {
@@ -32,8 +41,30 @@ Rectangle {
id: rowArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: root.node.isFolder ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.node.isFolder) root.activated()
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
if (root.node.isFolder) folderMenu.openAtCursor(rowArea, mouse.x, mouse.y)
return
}
if (root.node.isFolder) root.activated()
}
}
CardMenu {
id: folderMenu
menuWidth: 200
entries: [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "play") QbzLocal.playFolder(root.node.path)
else QbzLocal.enqueue("folder", root.node.path, a)
}
}
Rectangle {