Files
qbz/crates/qbz-qt/qml/shell/Sidebar.qml
T
vicrodh acf23abc9b feat(qt): port the My QBZ domain — mixtapes, collections and the discography builder
The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.

Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.

QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.

Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.

Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.

SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.

Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.

cc
2026-07-29 21:23:45 -06:00

1090 lines
56 KiB
QML

// Left navigation sidebar — QML port of crates/qbz-ui/ui/shell/Sidebar.slint.
//
// Three states (ShellState.sidebar-state): 0 = open 240px (icon + label),
// 1 = mini (icons only), 2 = closed 0px. Width animates 160ms ease-in-out;
// the header's panel-left button cycles (QbzShell.cycleSidebar).
//
// MINI RAIL: 64px, 1:1 with Slint (state.slint:4075), holding a 34px row
// (Sidebar.slint:575/652) on symmetric 15px padding. It was 50px for a while —
// an owner deviation that turned out to put the rail's centre 5px left of the
// header control sitting directly above it; see `miniPadLeft` below and
// QbzTheme.sidebarMiniWidth for the measurement. Everything else
// in the mini state is 1:1 with Slint: rows centre their lone glyph, nested
// playlist rows collapse to zero, hovering a row names it in a bubble to the
// right (SidebarTooltip.slint) and clicking a FOLDER opens its playlists in a
// flyout to the right (SidebarFolderPopup.slint) — in mini a folder cannot
// expand in place, so a toggle there would be a control that does nothing.
//
// FILE LENGTH (>500): the mini folder flyout is a shared single instance
// declared at this root (never one Popup per row), which is what keeps it
// cheap; the hover bubble is not here at all any more — it is the shell's
// shared QbzTooltip overlay. The flyout is an extraction candidate into
// qml/shell/SidebarFolderFlyout.qml, but a new .qml also needs a build.rs
// entry and an unregistered type fails the WHOLE file at load, so they stay
// here until that glue lands.
//
// Top-level section nav rows (Discover / Library / Local Library / My QBZ)
// replicate SidebarNavRow: 34px rows, radius 6, 16px icons, 13px/w500
// labels, surface-hover on hover, Discover + Library HIDDEN while offline
// (ADR-010 mount-site gating). The whole block — rows AND the hairline under
// them — is mounted only while `QbzShell.navInSidebar` is ON; with it OFF the
// sections move to the header (HeaderBar.qml) and this sidebar starts at the
// PLAYLISTS toolbar, exactly like Sidebar.slint:724/817.
//
// The rows open the same dropdown FLYOUT their Slint counterparts do
// (SidebarNavRow: hover or click, panel to the RIGHT of the row, headed by
// the section name) — the panel, its behaviour and the entry catalog live in
// the shared NavFlyout.qml, which HeaderBar.qml mounts too, so the sections
// behave identically wherever the nav is hosted. The rows keep the
// SidebarDirectRow active treatment (surface-hover bg + primary text/icon)
// for the current section and while their own menu is open.
// All four sections route into real views, My QBZ included (its Collections /
// Mixtapes entries reach the "collections" / "mixtapes" routes). The disabled
// treatment the NavRow still carries — dimmed to 0.45, pointer-inert, no
// flyout — is the mechanism for a section with no surface behind it, and
// nothing uses it today (a row that clicks into nothing is a defect, not a
// stub). Its leading glyph goes through shell/NavSectionGlyph.qml, because My
// QBZ can carry a user-supplied icon (SidebarNavRow `raw-icon`).
// The playlist/folder tree below the nav IS live (sidebar_qt.rs: load,
// sort, search, expand/collapse, drag-drop target); folder CREATION,
// the Playlist Manager and the importer have no seam and are disabled in
// the "..." menu.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../theme"
Rectangle {
id: root
property bool mini: QbzShell.sidebarState === 1
// The shell's ONE hover-tooltip overlay (controls/QbzTooltip.qml), fed in
// by AppShell. Null when this component is mounted in isolation, in which
// case the rail simply shows no bubble.
property Item tooltip: null
// ---- Collapsed-rail optical centring ---------------------------------
// MEASURED, not nudged (release binary, 1600x1000, xcb, pixels sampled off
// the framebuffer). The finding that mattered: the rail's content was
// ALREADY symmetric on the sidebar's own geometry — hover pill centre 25.0
// against a rail centre of 25.0 — and the owner still read it as off. The
// eye was not comparing the glyphs to the rail. It was comparing them to
// the header's leading control, the panel-left sidebar toggle, which sits
// at x = spacingMd(16) and is 28px wide (HeaderBar.qml:225 +
// QbzNavButton.qml:102) -> centre 30, directly above them. Five pixels
// apart in one vertical line is visible; symmetric padding does not help.
//
// Nor is there a visible rail edge to centre against: the sidebar is
// surface-card and the content frame's 8px left gutter is surface-card too
// (AppShell.qml:149 paints the panel; the frame around it is the shell
// base), so it reads as one uninterrupted band, sampled (26,26,26) from
// x=1 to x=57 and (15,15,15) from 58 — band centre 29.
//
// The rail is back at Slint's 64px, which dissolves the conflict instead
// of trading one misalignment for another: row centre 32 vs header button
// 30. See QbzTheme.sidebarMiniWidth for why 50 was the actual defect.
// Padding is symmetric and DERIVED, so the pair can never drift apart.
readonly property int miniPadLeft: (theme.sidebarMiniWidth - theme.sidebarMiniRow) / 2
readonly property int miniPadRight: root.miniPadLeft
// Which section owns the highlight. DERIVED from the live view (in
// NavFlyout, shared with the header), not set on click: the same rows can
// live here or in the header, and history navigation (back/forward) must
// keep the highlight honest. Slint additionally highlights the row whose
// menu is open — the rows OR `navFlyout.openId` in for that.
readonly property string activeNav: navFlyout.activeSection
// Playlist tree state (phase 7).
property bool searchOpen: false
property bool playlistsCollapsed: false
property string activePlaylistId: ""
// ---- Large-NPB dock space reservation -------------------------------
// The cover+spectrum dock is an AppShell-ROOT overlay pinned flush to the
// WINDOW bottom-left, so it extends the bar's height BELOW this sidebar
// (which stops at npb.top). Only the IN-SIDEBAR portion has to be
// reserved, and it shrinks when the band is hidden — that is why the
// height comes from the bridge and not a literal.
//
// Without this the playlist list keeps running to the sidebar's bottom
// edge and the rows sit UNDER the album art (Sidebar.slint:1145 reserves
// the same way).
readonly property bool largeDockActive: QbzShell.npbMode === 3 && QbzShell.sidebarState === 0
readonly property real dockReserve: largeDockActive
? Math.max(0, QbzShell.largeDockHeight - theme.npbLargeHeight)
: 0
// Flattened entries from the bridge + the url-keyed cover map.
readonly property var entries: parseEntries(QbzShell.sidebarJson)
// A republish rebuilds the Repeater's delegates, so the row a bubble is
// anchored to may be gone or moved and its MouseArea will never report the
// leave. The overlay outlives the delegate by design (it stores numbers,
// never the Item), so nothing dangles — but it would linger, so drop it.
onEntriesChanged: if (root.tooltip) root.tooltip.hideAll()
property var coverMap: ({})
function parseEntries(json) {
var e = JSON.parse(json)
// Collect every cover url and dispatch the artwork window.
var urls = []
for (var i = 0; i < e.length; i++) {
for (var j = 0; j < e[i].covers.length; j++) urls.push(e[i].covers[j])
}
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
return e
}
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
QbzTheme { id: theme }
// The section-nav dropdown (catalog + panel + open/close behaviour) —
// the SAME component HeaderBar.qml mounts. Zero-size and outside the
// Column so its origin stays this root's origin (the row coordinates are
// mapped into it). The panel renders in the window overlay, so neither
// this root's `clip` nor its 240px width can cut it off.
NavFlyout { id: navFlyout }
// ---- Collapsed (mini) affordances -----------------------------------
// In the mini rail a playlist row is an icon and nothing else, so the two
// things the open sidebar gives for free have to be restored:
// - the NAME, as a hover bubble to the row's right (SidebarTooltip.slint);
// - a folder's CONTENTS, as a flyout to the row's right, because the
// nested rows are hidden in mini (SidebarFolderPopup.slint).
// Both are ONE shared instance driven by the hovered/clicked row (exactly
// like Slint's SidebarTooltipState / SidebarFolderPopupState), never one
// per delegate: the tree can hold hundreds of rows.
// Neither can live inside this root's bounds, because it clips. The FLYOUT
// is a QtQuick.Controls Popup, so its content is reparented to the WINDOW
// overlay — the same reason NavFlyout and HeaderBar's playlists flyout are
// Popups. The BUBBLE is not a popup at all: it is the shell-level
// QbzTooltip overlay (`root.tooltip`, fed in by AppShell), for the reasons
// spelled out where the old ToolTip used to be declared, below.
// Race-safe show/hide (Slint SidebarRow.show-tooltip / hide-tooltip): the
// leaving row only clears the bubble if it still owns it, so sliding the
// pointer straight from one row to the next never blanks it. Both halves
// now live in the shared overlay, keyed by the entry id.
function showMiniTip(row, entry) {
if (!root.mini || !root.tooltip)
return
root.tooltip.showRight(row, entry.id, entry.kind === "folder"
? entry.name + " (" + entry.count + ")"
: entry.name)
}
function hideMiniTip(id) {
if (root.tooltip)
root.tooltip.hide(id)
}
// Mini folder click: the flyout, not a toggle (the nested rows are hidden,
// so toggling is a control that renders and does nothing — Slint opens the
// popup instead, Sidebar.slint:355-377).
function openFolderFlyout(row, entry) {
folderFlyout.folderId = entry.id
folderFlyout.folderName = entry.name
folderFlyout.folderCount = entry.count
// The children are only present in the published entries while the
// folder is EXPANDED. Expand it if it is not — never toggle blindly:
// a folder left open before the sidebar collapsed would be CLOSED by a
// toggle and the flyout would list nothing (and while a search query is
// active every folder is force-expanded, sidebar_qt.rs:299).
if (!entry.expanded) QbzShell.sidebarToggleFolder(entry.id)
var p = row.mapToItem(root, row.width + 6, 0)
folderFlyout.x = p.x
folderFlyout.y = p.y
// The flyout takes the same +6px slot the bubble occupies, and it is a
// Popup (window overlay) so it would paint straight over it. Slint drops
// the tooltip explicitly here too (Sidebar.slint:353).
if (root.tooltip) root.tooltip.hideAll()
folderFlyout.open()
}
// Cycling the sidebar while either is open would leave it floating over a
// rail that no longer exists (the bubble self-hides through `root.mini`;
// the flyout needs telling).
Connections {
target: QbzShell
function onSidebarStateChanged() {
folderFlyout.close()
// The bubble's anchor is a rail row that is about to move or stop
// existing (SidebarTooltip.slint:14 gates on sidebar-mini for the
// same reason).
if (root.tooltip) root.tooltip.hideAll()
}
}
// The hover bubble that names a collapsed row is NOT declared here any
// more: it is the shell's shared overlay (controls/QbzTooltip.qml, mounted
// once by AppShell), which this file drives through showMiniTip /
// hideMiniTip above. Two reasons, in order of severity:
//
// 1. THE OLD ONE WAS BROKEN. It was a QtQuick.Controls `ToolTip` with a
// custom contentItem, and a ToolTip does not propagate a custom
// content's implicit size: measured on Qt 6.11.1, an identical Text
// inside a `Popup` gives implicitContentWidth 157.8 and inside a
// `ToolTip` gives 1. The Basic style then sizes the popup to
// `implicitContentWidth + padding`, so the bubble collapsed to ~13x25
// while the Text — which a Popup never clips — kept painting its full
// 178px: the label floated over the content pane with no background and
// a small detached box sat beside it (owner report, reproduced and
// pixel-sampled before the change).
// 2. Slint does not put a popup per call site either. It has ONE overlay
// fed by a global channel (SidebarTooltipState, state.slint:1893;
// TooltipState, state.slint:4791), precisely so a clipped surface — and
// this sidebar clips — can still show a bubble outside its own bounds.
// Folder flyout — SidebarFolderPopup.slint: 230px, 30px header row +
// hairline + up to four 32px rows, 4px inner padding, surface-main /
// radius sm / 1px border-muted.
Popup {
id: folderFlyout
width: 230
padding: 0
topPadding: 4
bottomPadding: 4
// Slint's literal panel height (34 + 1 + list + 8) — 4px taller than
// the content, kept 1:1 rather than tightened.
height: 34 + 1 + folderFlyout.visibleRows * 32 + 8
// Replaces Slint's full-window scrim TouchArea (SidebarFolderPopup
// .slint:56-60); the Popup's own background swallows clicks inside,
// so no hand-built scrim and no click-through.
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
property string folderId: ""
property string folderName: ""
property int folderCount: 0
// The rows are a BINDING over the published entries, never a snapshot
// read inside the click handler: sidebarToggleFolder republishes
// sidebarJson through the cxx-qt UI queue (shell_bridge.rs::ui ->
// CxxQtThread::queue), so the children land a LATER event-loop turn.
// Reading `root.entries` at click time would open an empty panel.
readonly property var rows: root.entries.filter(function (e) {
return e.kind !== "folder" && e.folderId === folderFlyout.folderId
})
// Height off the folder's own count (known at click time) so the panel
// does not resize when the children land a tick later.
readonly property int visibleRows:
Math.min(Math.max(folderFlyout.folderCount, folderFlyout.rows.length), 4)
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
spacing: 0
// Header — folder name (30px, 12px side padding, 14px accent
// folder-open glyph, 13px/600 text-primary).
Item {
width: parent.width
height: 30
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 8
QbzIcon {
name: "folder-open"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
Text {
width: parent.width - 22
height: parent.height
text: folderFlyout.folderName
color: theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightSemibold
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// Playlist list — four rows visible, the rest scroll (the shared
// QbzScrollBar mounts past four, like Slint's ListScrollbar).
Item {
width: parent.width
height: folderFlyout.visibleRows * 32
Flickable {
id: fpFlick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: fpColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: fpColumn
width: parent.width
Repeater {
model: folderFlyout.rows
delegate: Rectangle {
id: fpRow
required property var modelData
width: fpColumn.width
height: 32
radius: 5
color: fpArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 8
QbzIcon {
name: "list-music"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
Text {
width: parent.width - 22
height: parent.height
text: fpRow.modelData.name
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: fpArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.activePlaylistId = fpRow.modelData.id
QbzBridge.openPlaylist(fpRow.modelData.id)
folderFlyout.close()
}
}
}
}
}
}
QbzScrollBar {
visible: folderFlyout.folderCount > 4 && fpFlick.contentHeight > fpFlick.height
target: fpFlick
width: 10
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
width: QbzShell.sidebarState === 2 ? 0
: QbzShell.sidebarState === 1 ? theme.sidebarMiniWidth
: theme.sidebarOpenWidth
// surface-card @ 0.5 while the ambient background is active (phase 14).
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
// Square edges; clip keeps content from spilling while the width
// animates (same as the Slint root).
clip: true
Behavior on width {
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
}
// One section-nav row (SidebarNavRow / SidebarDirectRow metrics). Hovering
// or clicking it opens that section's flyout to the RIGHT of the row.
component NavRow: Rectangle {
id: navRow
// A section from the shared catalog (NavFlyout.sections).
property var section: null
readonly property bool isActive: section
&& (root.activeNav === section.id || navFlyout.openId === section.id)
// A section with no surface behind it renders as unavailable rather
// than as a live row that clicks into nothing. Every section is live
// today; the treatment stays for the next stub.
readonly property bool isEnabled: section && section.enabled
readonly property bool lit: isActive || (navArea.containsMouse && isEnabled)
width: parent ? parent.width : 0
// Mini: a SQUARE row that fills the 34px padded track (theme token,
// rail 50 - 2*8). Open keeps Slint's 34px (Sidebar.slint:575).
height: root.mini ? theme.sidebarMiniRow : 34
radius: 6
opacity: isEnabled ? 1.0 : 0.45
color: lit ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: root.mini ? 0 : 8
anchors.rightMargin: root.mini ? 0 : 8
spacing: 10
Item {
width: root.mini ? parent.width : 16
height: parent.height
// Baked glyph, or the section's own raw image when it carries
// one (My QBZ branding) — Sidebar.slint:601-612.
NavSectionGlyph {
section: navRow.section
size: 16
anchors.centerIn: parent
tintName: navRow.lit ? "textPrimary" : "secondary"
}
}
Text {
visible: !root.mini
height: parent.height
width: parent.width - (root.mini ? 0 : 26)
text: navRow.section ? navRow.section.label : ""
color: navRow.lit ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: navArea
anchors.fill: parent
enabled: navRow.isEnabled
hoverEnabled: navRow.isEnabled
cursorShape: Qt.PointingHandCursor
onClicked: navFlyout.openBeside(navRow, navRow.section)
onContainsMouseChanged: {
if (containsMouse) {
// Hover-to-open; hovering another row overwrites the open
// menu (instant switch, single-open by construction).
navFlyout.triggerHovered = true
navFlyout.openBeside(navRow, navRow.section)
} else if (navRow.section && navFlyout.openId === navRow.section.id) {
// Only the row owning the open menu clears the flag.
navFlyout.triggerHovered = false
}
}
}
}
Column {
anchors.fill: parent
// Mini left/right are ASYMMETRIC on purpose — see `miniPadLeft` at the
// top of this file for the measurement. Top stays 8 (nothing above the
// rail competes with it vertically).
anchors.leftMargin: root.mini ? root.miniPadLeft : theme.spacingMd
anchors.rightMargin: root.mini ? root.miniPadRight : theme.spacingMd
anchors.topMargin: root.mini ? 8 : theme.spacingMd
// Per-side padding so Large can zero ONLY the bottom (Sidebar.slint:719):
// the reserved dock band then reaches the true window bottom-left and the
// cover sits flush (the L corner). Left/right/top are unchanged.
anchors.bottomMargin: root.largeDockActive ? 0 : (root.mini ? 8 : theme.spacingMd)
spacing: theme.spacingMd
// ---- Section nav -------------------------------------------
Column {
id: navColumn
// Section-nav placement: OFF moves the whole block to the header.
// A Column skips invisible children entirely (no phantom spacing),
// so this is the `if` mount-site gate Slint uses.
visible: QbzShell.navInSidebar
width: parent.width
spacing: 2
// The same four sections the header hosts, from the same catalog.
// Qobuz-only ones are HIDDEN entirely while offline (ADR-010
// mount-site gating, not rendered-disabled). My QBZ is `qobuz:
// false` in the catalog, i.e. it stays mounted while offline —
// Slint does not offline-gate its row either
// (Sidebar.slint:779-792, unlike the Purchases row right after it).
Repeater {
model: navFlyout.sections
delegate: NavRow {
required property var modelData
section: modelData
visible: !(modelData.qobuz && QbzSession.offline)
}
}
}
// Hairline under the section nav — mounted with the nav block, not
// on its own (Sidebar.slint:817 gates it on the same flag).
Rectangle {
visible: QbzShell.navInSidebar
width: parent.width
height: 1
color: theme.borderSubtle
}
// ---- Playlists header toolbar (hidden in the mini state) -----
Row {
visible: !root.mini
width: parent.width
height: 22
spacing: 4
// Title (hidden while the search input is open).
Text {
visible: !root.searchOpen
width: parent.width - 4 * 26
height: parent.height
text: QbzSession.tr("PLAYLISTS", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 10
font.letterSpacing: 1
verticalAlignment: Text.AlignVCenter
}
// Inline search input (filters entries, recursive).
Rectangle {
visible: root.searchOpen
width: parent.width - 4 * 26
height: 22
radius: 4
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
TextInput {
id: searchEdit
anchors.fill: parent
anchors.leftMargin: 6
color: theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: QbzShell.sidebarSearch(text)
Text {
visible: searchEdit.text === ""
anchors.fill: parent
text: QbzSession.tr("Search playlists", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
}
}
// Search toggle.
Rectangle {
width: 22
height: 22
radius: 4
color: (searchBtnArea.containsMouse || root.searchOpen) ? theme.surfaceHover : "transparent"
QbzIcon {
name: "search"
width: 15
height: 15
anchors.centerIn: parent
tintName: (searchBtnArea.containsMouse || root.searchOpen) ? "textPrimary" : "muted"
}
MouseArea {
id: searchBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.searchOpen = !root.searchOpen
if (!root.searchOpen) {
searchEdit.text = ""
QbzShell.sidebarSearch("")
} else {
searchEdit.forceActiveFocus()
}
}
}
}
// New playlist (+) — single core call (create_playlist), then
// reload the tree. Slint opens a naming modal; the POC creates
// with the default name (POC-NOTE).
Rectangle {
width: 22
height: 22
radius: 4
color: plusArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "plus"
width: 16
height: 16
anchors.centerIn: parent
tintName: plusArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: plusArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.createPlaylist()
}
}
// Sort / more (...) menu.
Rectangle {
width: 22
height: 22
radius: 4
color: sortBtnArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "ellipsis"
width: 15
height: 15
anchors.centerIn: parent
tintName: sortBtnArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: sortBtnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: sortMenu.open()
}
Popup {
id: sortMenu
// Opens to the RIGHT of the "..." (overlay into the
// content area) — owner request in Sidebar.slint.
x: parent.width + 18
y: 26
width: 200
padding: 6
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceCard
radius: 8
border.width: 1
border.color: theme.borderSubtle
}
contentItem: Column {
spacing: 1
Row {
leftPadding: 8
topPadding: 4
bottomPadding: 2
spacing: 6
QbzIcon { name: "arrow-up-down"; width: 13; height: 13; tintName: "muted" }
Text {
text: QbzSession.tr("Sort by", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
}
}
Repeater {
model: [
{ "opt": "name", "label": QbzSession.tr("Name (A-Z)", QbzSession.trRev) },
{ "opt": "recent", "label": QbzSession.tr("Recent", QbzSession.trRev) },
{ "opt": "tracks", "label": QbzSession.tr("# of tracks", QbzSession.trRev) },
{ "opt": "playcount", "label": QbzSession.tr("Play Count", QbzSession.trRev) },
{ "opt": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev) },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 30
radius: 5
color: sortOptArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 6
Text {
width: parent.width - 20
height: parent.height
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
font.weight: QbzShell.sidebarSortBy === modelData.opt ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
}
QbzIcon {
visible: QbzShell.sidebarSortBy === modelData.opt
name: QbzShell.sidebarSortAsc ? "chevron-up" : "chevron-down"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
}
MouseArea {
id: sortOptArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
QbzShell.sidebarSetSort(modelData.opt)
sortMenu.close()
}
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// New folder / Manage playlists / Import — DISABLED.
// GAP, not a half-path: this port has no folder
// creation seam (`sidebar_qt.rs` exposes only
// load/sort/search/toggle-folder — no create/delete),
// no Playlist Manager view, and no importer. They used
// to render as live-looking rows (hover highlight +
// pointing-hand cursor) whose click did nothing at
// all; a control that renders and no-ops is a defect,
// so they now read as unavailable: dimmed, no hover
// treatment, no pointer cursor, no MouseArea. Restore
// the interactive form together with the seam.
Repeater {
model: [
{ "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev) },
{ "icon": "library-big", "label": QbzSession.tr("Manage playlists", QbzSession.trRev) },
{ "icon": "import", "label": QbzSession.tr("Import", QbzSession.trRev) },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 30
radius: 5
color: "transparent"
opacity: 0.4
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: modelData.icon; width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
Text {
height: parent.height
text: modelData.label
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
}
}
// Collapse chevron (down = expanded, right = collapsed).
Rectangle {
width: 22
height: 22
radius: 4
color: collapseArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: root.playlistsCollapsed ? "chevron-right" : "chevron-down"
width: 15
height: 15
anchors.centerIn: parent
tintName: collapseArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: collapseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.playlistsCollapsed = !root.playlistsCollapsed
}
}
}
// ---- Playlist tree (folders + playlists, sidebar_qt.rs) --------
Item {
visible: !root.playlistsCollapsed
width: parent.width
// This is the element that FILLS (Sidebar.slint gives the playlist
// Rectangle `vertical-stretch: 1`): everything above it — the nav
// block, the hairline, the 22px toolbar — takes its natural height
// and the list eats the rest, down to the Column's bottom margin.
// `root.dockReserve` keeps the rows clear of the Large-NPB cover
// (0 in every other mode). Clamped so a short window shrinks the
// list to nothing instead of giving it a negative height.
//
// There used to be a trailing `Item { height: 0 }` "filler" after
// this one and a matching `- 16` here to pay for the Column spacing
// it introduced. Net effect: the list stopped 16px short and the
// sidebar read as too short against Slint. Both are gone — the
// list is the last child, so its bottom IS the Column's bottom.
height: Math.max(0, parent.height - y - root.dockReserve)
Flickable {
id: plFlick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: plColumn.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: plColumn
width: parent.width
spacing: 2
Repeater {
model: root.entries
delegate: Rectangle {
id: plRow
required property var modelData
property bool isFolder: modelData.kind === "folder"
// Slint SidebarRow.use-collage: opt-OUT setting AND
// at least one cover; otherwise the list-music glyph.
property bool useCollage: !isFolder
&& QbzShell.sidebarPlaylistCollage
&& modelData.covers.length > 0
property bool isActive: !isFolder && modelData.id === root.activePlaylistId
// Track-drag drop target (Sidebar.slint SidebarRow):
// the row self-detects the drop from the shared
// window-coord pointer and claims/releases it.
property bool dropHot: false
function recomputeDrop() {
if (isFolder || !QbzShell.dragActive) {
if (dropHot) { QbzShell.dragSetOver(""); dropHot = false }
return
}
const tl = mapToItem(null, 0, 0)
const hot = QbzShell.dragX >= tl.x && QbzShell.dragX <= tl.x + width
&& QbzShell.dragY >= tl.y && QbzShell.dragY <= tl.y + height
if (hot && !dropHot) QbzShell.dragSetOver(modelData.id)
else if (!hot && dropHot) QbzShell.dragSetOver("")
dropHot = hot
}
Connections {
target: QbzShell
function onDragXChanged() { recomputeDrop() }
function onDragYChanged() { recomputeDrop() }
function onDragActiveChanged() { recomputeDrop() }
}
width: plColumn.width
// Mini: nested rows collapse to nothing (their
// folder opens the flyout instead, Sidebar.slint:
// 213-215), top-level rows become the SAME square
// the nav rows are — a 34px row in a 34px track.
// Slint keeps 32px here; the 2px come from the
// narrower rail (theme.sidebarMiniRow).
height: root.mini
? (modelData.indent ? 0 : theme.sidebarMiniRow)
: 32
visible: !(root.mini && modelData.indent)
radius: 6
// success-bg + success-border while hot (Theme
// success family, #3fae6a at 10% / 30%).
color: dropHot ? "#3fae6a1a"
: ((rowArea.containsMouse || isActive) ? theme.surfaceHover : "transparent")
border.width: dropHot ? 1 : 0
border.color: "#3fae6a4d"
Row {
anchors.fill: parent
anchors.leftMargin: root.mini ? 0 : (modelData.indent ? 24 : 8)
anchors.rightMargin: root.mini ? 0 : 6
spacing: 8
// Leading icon slot: 24px fixed while open (so
// every row's name starts at the same x), the
// WHOLE row in mini so the lone glyph lands on
// the rail's centre line — Slint gets this from
// `alignment: mini ? center : stretch`
// (Sidebar.slint:230), which a QML Row has no
// equivalent for. Without it the slot is the
// only visible child, sits at x=0, and the
// playlist/folder glyphs render 12px LEFT of
// the nav glyphs above them (owner screenshot).
// Same idiom as NavRow's slot above.
Item {
width: root.mini ? parent.width : 24
height: parent.height
// Folder icon (accent). Every glyph in this
// slot is placed with rounded arithmetic
// (Sidebar.slint:245-246): `centerIn` of an
// odd 15px glyph in an even track lands on a
// half pixel and blurs it.
QbzIcon {
visible: isFolder
name: modelData.expanded && !root.mini ? "folder-open" : "folder"
width: 15
height: 15
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
tintName: "accent"
}
// 2x2 micro-collage (or list-music glyph).
Rectangle {
visible: useCollage
width: 20
height: 20
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
radius: 3
color: theme.surfaceElevated
clip: true
Grid {
anchors.fill: parent
columns: 2
Repeater {
model: modelData.covers.slice(0, 4)
delegate: Image {
required property string modelData
width: 10
height: 10
source: root.coverMap[modelData] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
}
}
QbzIcon {
visible: !isFolder && !useCollage
name: "list-music"
width: 15
height: 15
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
tintName: (rowArea.containsMouse || isActive) ? "textPrimary" : "muted"
}
}
// Name (hidden in mini). A Row is a positioner
// with no stretch, so this width must reserve
// EVERY later cell + its spacing or the excess
// is pushed past the row's right edge and the
// `clip: true` on the Flickable eats it.
// (Sidebar.slint gets this free: its
// HorizontalLayout stretches the name cell.)
// Reserve: 8 + 24 icon slot always; for a
// FOLDER also 8 + 14 chevron, plus 8 + the
// count label while it is laid out. The old
// flat -22 ignored the count, pushing the
// chevron ~15px past the clip — which is why
// the caret vanished on exactly the folders
// that HAVE children (owner report).
Text {
visible: !root.mini
width: Math.max(0, parent.width - 32
- (isFolder
? 22 + (countLabel.visible
? countLabel.implicitWidth + 8 : 0)
: 0))
height: parent.height
text: modelData.name
color: (rowArea.containsMouse || isActive) ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
font.weight: isFolder ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Folder count (hover) + chevron. The count is
// hover-FADED, not hidden — opacity does not
// free layout space, so it must stay in the
// name's reserve above at all times.
Text {
id: countLabel
visible: !root.mini && isFolder && modelData.count > 0
height: parent.height
text: modelData.count
color: theme.textMuted
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
opacity: rowArea.containsMouse ? 1.0 : 0.0
}
QbzIcon {
visible: !root.mini && isFolder
name: modelData.expanded ? "chevron-down" : "chevron-right"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: rowArea.containsMouse ? "textPrimary" : "muted"
}
}
// Active-row redundant shape cue: 3px accent bar.
Rectangle {
visible: isActive
x: 0
width: 3
height: parent.height - 12
anchors.verticalCenter: parent.verticalCenter
radius: 1.5
color: theme.accent
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Mini: the row is an icon, so hovering names
// it (Sidebar.slint:342-349). No-op while open.
onContainsMouseChanged: {
if (containsMouse) root.showMiniTip(plRow, plRow.modelData)
else root.hideMiniTip(plRow.modelData.id)
}
onClicked: {
// Slint drops the bubble on click so it
// does not linger behind the flyout.
root.hideMiniTip(plRow.modelData.id)
if (isFolder) {
// Mini: the nested rows are hidden, so
// a toggle would change NOTHING on
// screen — the contents open in a
// flyout to the right instead
// (Sidebar.slint:355-377).
if (root.mini) root.openFolderFlyout(plRow, plRow.modelData)
else QbzShell.sidebarToggleFolder(modelData.id)
} else {
root.activePlaylistId = modelData.id
QbzBridge.openPlaylist(modelData.id)
}
}
}
}
}
// Empty state. NOTE: Slint does NOT gate this on mini
// (Sidebar.slint:1077-1082) and word-wraps the sentence
// inside the 48px track; keeping the gate here is a
// deliberate, owner-visible divergence.
Text {
visible: !root.mini && root.entries.length === 0 && !QbzSession.offline
width: plColumn.width
text: QbzSession.tr("No playlists yet.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
}
// List scrollbar (Sidebar.slint:1090-1137). The shared
// QbzScrollBar — the port's ListScrollbar replica — rather than a
// third scrollbar implementation. Two accepted deviations from
// the sidebar's bespoke Slint one: it reveals on SCROLL (900ms
// auto-hide) instead of on row-hover, and its thumb is the
// standard 8/10px instead of the sidebar's thinner 3-6px.
// Placed in the sidebar's right PADDING (negative margin), like
// Slint's `x: parent.width - width + (mini ? 8 : Spacing.md)`, so
// the gutter never overlays the rows and cannot eat their clicks.
//
// MINI CAVEAT after the optical re-centring above: the right pad is
// 4px now, so an 8px gutter pinned to the rail's edge shares its
// left 4px with the rows. That is fine to LOOK at (an 8px-wide,
// auto-hiding indicator whose thumb grazes the pill's rounded right
// edge) but not to CLICK through, so the mini instance is
// `enabled: false`: indicator only, no drag / click-to-position.
// Nothing is lost — the wheel scrolls the rail, and a 3px-of-travel
// drag inside a 50px rail was never a real affordance. The open
// sidebar keeps the full interactive bar.
QbzScrollBar {
target: plFlick
enabled: !root.mini
width: root.mini ? 8 : 10
anchors.right: parent.right
anchors.rightMargin: -(root.mini ? root.miniPadRight : theme.spacingMd)
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}