// 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): what remains after the mini folder flyout moved to // shell/SidebarFolderFlyout.qml and the row context menu to // shell/SidebarRowMenu.qml is ONE surface with one state machine — the three // sidebar widths. Every block below is a WIDTH-CONDITIONAL variant of the same // tree (the nav rows, the PLAYLISTS toolbar, the row delegate and the // scrollbar each read `root.mini`), so splitting them into files would hand // each half a `mini` property to keep in sync and put the row delegate's // drop-target arithmetic — which needs `root`'s own window coordinates — a // file away from the root it measures. The three things that DID come out are // the three that are self-contained: both overlays and the hover bubble (the // shell's shared QbzTooltip). All three are shared single instances declared // at this root, never one per row: the tree can hold hundreds of rows. // // 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`). // PURCHASES is the one row in that block that is NOT a section: it is a // DirectRow (SidebarDirectRow) — no dropdown, no catalog entry, it just // navigates — and it is triple-gated (opt-in `show_purchases`, hidden while // offline, and shown only while the sections themselves live here: with the // navigation in the header it follows them up there). The pref defaults // OFF, so on a stock install this block is still the same four section rows // it has always been. // The playlist/folder tree below the nav IS live (sidebar_qt.rs: load, sort, // search, expand/collapse, drag-drop target), and so is everything the "..." // menu offers: New folder opens QbzFolderEdit's create panel (the small // name-only create modal, controls/FolderModals.qml), Manage playlists routes // to the Playlist Manager, Import opens the importer and Refresh rebuilds the // tree. Import is the only row that dims — it is the only Qobuz-only one, so it // follows the reference's `enabled: !offline`; Refresh stays lit because its // bridge call is the offline-safe rebuild (see the Repeater's comment). Nothing // in this file renders a control that no-ops. // // Right-pressing a row opens shell/SidebarRowMenu.qml (edit / hide / mixtape / // move-to-folder). It is suppressed on the mini rail: Slint anchors that menu // at `row.width - 210px`, which on a 34px rail row is deeply negative and Qt's // viewport clamp would drop it at x=8 — not the reference's placement, just // the clamp. import QtQuick import QtQuick.Controls import QtQuick.Effects import com.blitzfc.qbz import "../controls" import "../theme" Rectangle { id: root property bool mini: QbzShell.sidebarState === 1 // Purchases downloading now — `QbzPurchases.activeDownloadsJson`, parsed // once per publish (guarded: "[]" on the pre-boot frame). readonly property var activeDownloads: { try { return JSON.parse(QbzPurchases.activeDownloadsJson || "[]") } catch (e) { return [] } } /// Mean completion across the active albums, 0..1, for the mini ring. readonly property real activeDownloadsOverall: { var rows = root.activeDownloads if (rows.length === 0) return 0 var sum = 0 for (var i = 0; i < rows.length; i++) sum += (rows[i].percent || 0) return sum / rows.length / 100 } /// The hover bubble's text: one line per album, "Title — 7/14 · 50%". readonly property string activeDownloadsSummary: { var rows = root.activeDownloads var lines = [] for (var i = 0; i < rows.length; i++) lines.push((rows[i].title || QbzSession.tr("Purchases", QbzSession.trRev)) + " — " + (rows[i].done || 0) + "/" + (rows[i].total || 0) + " · " + (rows[i].percent || 0) + "%") return lines.join("\n") } // 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 // ---- Purchases row gating (contract §7.1, Sidebar.slint:794-805) ----- // NO new bridge property is needed. `settings_bool`'s tail is // unconditional on success — `publish_snapshot()` re-serialises the WHOLE // settings document after every successful write (settings_qt.rs:2278-2285) // — and the pref is already in it (`showPurchases`). So the Navigation // toggle reaches this row through QbzBridge.settingsJson and nothing // else is wired. // // GUARDED parse, the NavFlyout.qml:65-70 precedent: a bare JSON.parse in a // binding throws on the pre-publish frame and would take the whole sidebar // down with it, not just this row. readonly property var settingsDoc: { try { return JSON.parse(QbzBridge.settingsJson) } catch (e) { return ({}) } } // `show_purchases` is the MASTER gate. Purchases then FOLLOWS the other // sections (2026-09-13): it sits here while the navigation lives in the // sidebar, and in the header beside them otherwise (HeaderBar.qml // `purchasesInHeader`) — never the one entry left behind. The pref // defaults false: Purchases ships hidden. readonly property bool purchasesVisible: root.settingsDoc.showPurchases === true && !QbzSession.offline && QbzShell.navInSidebar // Playlist tree state (phase 7). property bool searchOpen: false // PERSISTED collapse state (owner request 2026-09-10): read live from the // republished settingsJson, toggled through `settingsBool`, so a collapsed // section stays collapsed across restarts. Default expanded. readonly property bool playlistsCollapsed: settingsDoc.sidebarPlaylistsCollapsed === true property string activePlaylistId: "" // ---- Collapsible My QBZ tree (opt-in) ------------------------------- // Collapse state persisted the same way as the playlist tree above. The // parent "My QBZ" header collapses the whole block; each section collapses // its own list. readonly property bool myqbzCollapsed: settingsDoc.myqbzCollapsed === true readonly property bool myqbzMixtapesCollapsed: settingsDoc.myqbzMixtapesCollapsed === true readonly property bool myqbzCollectionsCollapsed: settingsDoc.myqbzCollectionsCollapsed === true // The light tree feed (myqbz_qt::publish_sidebar_tree): both grids' rows as // {mixtapes:[{id,name,hidden}], collections:[…]}. GUARDED parse, same // precedent as `settingsDoc`. Hidden rows are dropped here so the sidebar // honours the per-element "Hide from sidebar" flag. // EXPLICIT refresh, not a declarative binding: a plain `var` block binding // on QbzShell.myqbzTreeJson did not re-evaluate on republish here (the tree // stayed empty though Rust logged the feed). The grids drive their model the // same way — `onItemsChanged` + `Component.onCompleted` (MyQbzGridView.qml: // 152-153) — so mirror that: a Connections handler re-parses on every // change, and `Component.onCompleted` seeds whatever is already published. // PLAIN arrays assigned together in refreshMyqbzTree — NOT derived // bindings. A derived `var` binding on another `var` (myqbzMixtapes: // _visibleRows(myqbzTree.mixtapes)) did not re-evaluate when the source was // assigned imperatively (the feed arrived — proven in the log — yet the // rows stayed empty). Assigning the final arrays directly makes the // Repeater/empty-state bindings react the ordinary way. property var myqbzMixtapes: [] property var myqbzCollections: [] // Raw feed rows (hidden dropped), kept so a search / sort change re-derives // the visible arrays without a DB round trip. property var _myqbzMixRaw: [] property var _myqbzColRaw: [] // Playlists-parity toolbar state: inline search over the tree, and a simple // A–Z / Z–A name sort. property bool myqbzSearchOpen: false property string myqbzSearch: "" property bool myqbzSortAsc: true function _visibleRows(arr) { var out = [] if (!arr) return out for (var i = 0; i < arr.length; ++i) if (arr[i] && arr[i].hidden !== true) out.push(arr[i]) return out } // Apply the live search filter + name sort to one raw list. function _filterSortMyqbz(arr) { var q = root.myqbzSearch.trim().toLowerCase() var out = [] for (var i = 0; i < arr.length; ++i) { var r = arr[i] if (!r) continue if (q !== "" && String(r.name).toLowerCase().indexOf(q) === -1) continue out.push(r) } out.sort(function (a, b) { var an = String(a.name).toLowerCase() var bn = String(b.name).toLowerCase() if (an === bn) return 0 var lt = an < bn return root.myqbzSortAsc ? (lt ? -1 : 1) : (lt ? 1 : -1) }) return out } function _applyMyqbz() { root.myqbzMixtapes = _filterSortMyqbz(root._myqbzMixRaw) root.myqbzCollections = _filterSortMyqbz(root._myqbzColRaw) } function refreshMyqbzTree() { var t try { t = JSON.parse(QbzShell.myqbzTreeJson) } catch (e) { t = ({ "mixtapes": [], "collections": [] }) } root._myqbzMixRaw = _visibleRows(t.mixtapes) root._myqbzColRaw = _visibleRows(t.collections) root._applyMyqbz() } onMyqbzSearchChanged: root._applyMyqbz() onMyqbzSortAscChanged: root._applyMyqbz() Connections { target: QbzShell function onMyqbzTreeJsonChanged() { root.refreshMyqbzTree() } } Component.onCompleted: root.refreshMyqbzTree() // Master gate + the two section gates + the standard-entry suppressor. readonly property bool myqbzTreeOn: settingsDoc.collapsibleMyqbz === true readonly property bool myqbzShowMixtapes: settingsDoc.myqbzShowMixtapes !== false readonly property bool myqbzShowCollections: settingsDoc.myqbzShowCollections !== false // The collapsible tree REPLACES the flat My QBZ nav entry — its parent // header click reaches the same surface — so the two are mutually // exclusive: whenever the tree is on, the classic row is suppressed. (No // separate toggle; keeping both would be the same entry twice.) readonly property bool hideStandardMyqbz: myqbzTreeOn // ---- 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) } } /// Shader availability for the playlist micro-collage's rounding mask. /// Verbatim from cards/PlaylistCollage.qml:120-122 (and /// theme/RoundedImage.qml:234-236): the ONLY case the retired "effects /// render nothing" doctrine was ever true for is the software/Null /// renderer, and `GraphicsInfo.api` answers it per window at runtime /// (`api` carries `notify: "apiChanged"`, so this re-evaluates when the /// window arrives). Test Software/Null NEGATIVELY — under the RHI an /// OpenGL backend reports `GraphicsInfo.OpenGL`. `QbzShell.forceCanvasArt` /// (env `QBZ_QT_ROUND_MODE=canvas`, default OFF) is honoured here too, so /// a machine where `GraphicsInfo` reports a GPU and the mask still /// misbehaves degrades to square corners instead of losing the covers. /// `typeof` guard mirrors both files: eager binding, and the sidebar must /// stay loadable in an isolated qml6 scene. /// /// Declared ONCE on this root rather than per delegate: the answer is a /// per-window constant and the playlist tree can hold hundreds of rows. readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software || GraphicsInfo.api === GraphicsInfo.Null || (typeof QbzShell !== "undefined" && QbzShell.forceCanvasArt) 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). // // It no longer force-expands the folder on the way in. That used to be // necessary because the flyout filtered the published `entries`, which // carry a folder's children ONLY while it is expanded — a persistent side // effect (the folder stayed open once the sidebar was re-opened) the // reference does not have. The flyout now asks Rust for a dedicated // cache-built document instead (SidebarFolderFlyout.qml / §4.7). function openFolderFlyout(row, entry) { // 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.openFor(entry, row) } // Cycling the sidebar while any of the three is open would leave it // floating over a rail that no longer exists (the bubble self-hides // through `root.mini`; the two Popups need telling). Connections { target: QbzShell function onSidebarStateChanged() { folderFlyout.close() // The row menu is row-anchored and the rows are about to change // width — or, going to state 2, stop existing. rowMenu.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 (mini rail) and the row context menu — ONE shared instance // each, declared at this root, never one per delegate: the tree can hold // hundreds of rows. Both are Popups, so their content is reparented to the // WINDOW overlay and this root's `clip: true` cannot cut them off. SidebarFolderFlyout { id: folderFlyout onPlaylistActivated: function (id) { root.activePlaylistId = id } } // The canonical folder list, parsed once. `foldersJson` is the Playlist // Manager's property because the manager owns the rich folder record, but // it is kept fresh independently of the manager's document cache // (refresh_folders()), so the sidebar can read it before — and without — // the manager view ever being opened. readonly property var pmFolders: { try { return JSON.parse(QbzPlaylistManager.foldersJson) } catch (e) { return [] } } // Hidden folders are dropped EVERYWHERE in the sidebar tree, so the row // menu must not offer them, must not count them toward its search // threshold and must not let them suppress its empty state. Derived once, // here, and read three times over there. readonly property var visibleFolders: root.pmFolders.filter(function (f) { return f.isHidden !== true }) SidebarRowMenu { id: rowMenu visibleFolders: root.visibleFolders } // Right-click menu for a collapsible My QBZ tree row (mixtape/collection): // one action, "Hide from sidebar". MyQbzSidebarMenu { id: myqbzRowMenu } // My QBZ header "+" — create a new Mixtape or Collection (app-global modal). CardMenu { id: myqbzPlusMenu menuWidth: 190 entries: [ { "label": QbzSession.tr("New Mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" }, { "label": QbzSession.tr("New Collection", QbzSession.trRev), "icon": "library-big", "action": "collection" }, ] onPicked: function (a) { QbzShell.myqbzCreateOpen(a) } } // My QBZ header "..." — a simple name sort plus refresh. CardMenu { id: myqbzMoreMenu menuWidth: 190 entries: [ { "label": QbzSession.tr("Name (A-Z)", QbzSession.trRev), "icon": "chevron-down", "action": "az" }, { "label": QbzSession.tr("Name (Z-A)", QbzSession.trRev), "icon": "chevron-up", "action": "za" }, { "sep": true }, { "label": QbzSession.tr("Refresh", QbzSession.trRev), "icon": "refresh-cw", "action": "refresh" }, ] onPicked: function (a) { if (a === "az") root.myqbzSortAsc = true else if (a === "za") root.myqbzSortAsc = false else if (a === "refresh") QbzShell.myqbzRefreshTree() } } // #771: the open width is the user's (240..480, persisted); the other // two states keep their fixed rails. width: QbzShell.sidebarState === 2 ? 0 : QbzShell.sidebarState === 1 ? theme.sidebarMiniWidth : QbzShell.sidebarWidth /// Set by AppShell while its resize handle is pressed: the width follows /// the pointer directly, the state snaps still animate. property bool resizing: false // surface-card @ 0.5 while the ambient background is active (phase 14). color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard readonly property bool ambientOn: theme.ambientOn // Square edges; clip keeps content from spilling while the width // animates (same as the Slint root). clip: true // While the pointer drives the width the Behavior is off (it would lag // the mouse); a STATE snap during that drag (open <-> mini <-> closed) // still animates, because the bridge raises `sidebarSnapping` right // before it changes the state and lowers it on the next pointer-follow. Behavior on width { enabled: !root.resizing || QbzShell.sidebarSnapping 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, root.mini) // Opt-in "click lands on the first tab" — a no-op while the // Appearance toggle is off, which is the default. navFlyout.sectionClicked(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, root.mini) } else if (navRow.section && navFlyout.openId === navRow.section.id) { // Only the row owning the open menu clears the flag. navFlyout.triggerHovered = false } } } } // A DIRECT destination row — the port of SidebarDirectRow // (Sidebar.slint:646-685). Same 34px metrics, same radius, same lit // treatment as NavRow above; what it does NOT have is the flyout. It // navigates on click, so there is no catalog `section` behind it — which // is also why the glyph goes to QbzIcon directly rather than through // shell/NavSectionGlyph.qml (that component's whole input IS a section). // `shopping-bag` is a registered tint master (icon_tint_qt.rs:261), so it // is available in every tint under all 36 themes. // // IT MUST NEVER TOUCH `navFlyout.triggerHovered`, and the reason is the // opposite of the obvious one. The idle-close timer is // `running: panel.visible && !panel.menuHovered` (NavFlyout.qml:285), so // with nothing open the flag pins nothing. The hazard is setting it while // ANOTHER section's panel IS open: it feeds `menuHovered` (:368-369), // which halts the 1s idle-close and leaves that flyout hanging open for as // long as the pointer rests on this row. Hovering a direct row is not a // flyout event at all. component DirectRow: Rectangle { id: directRow // A QbzIcon glyph name (not a section) and its label. property string iconName: "" property string label: "" property bool active: false signal activated() readonly property bool lit: directRow.active || directArea.containsMouse width: parent ? parent.width : 0 // Mini: the SAME square track the nav rows use, so a direct row and a // section row cannot land on different centre lines. height: root.mini ? theme.sidebarMiniRow : 34 radius: 6 color: directRow.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 QbzIcon { name: directRow.iconName width: 16 height: 16 anchors.centerIn: parent tintName: directRow.lit ? "textPrimary" : "secondary" } } Text { visible: !root.mini height: parent.height width: parent.width - (root.mini ? 0 : 26) text: directRow.label color: directRow.lit ? theme.textPrimary : theme.textSecondary font.pixelSize: 13 font.weight: theme.weightMedium verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } } MouseArea { id: directArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: directRow.activated() } } // A collapsible My QBZ tree HEADER: "My QBZ" / "Mixtapes" / "Collections". // TWO hit-areas by owner intent — the TEXT is a link to that surface, the // CHEVRON only collapses its list. `indent` nests the section headers under // the parent. Muted, discreet; not a full nav row. component MyQbzHeader: Rectangle { id: mqHeader property string label: "" property bool expanded: true property int indent: 0 property bool showChevron: true signal linkClicked() signal toggle() width: parent ? parent.width : 0 height: 30 radius: 6 color: (mqLinkArea.containsMouse || mqChevArea.containsMouse) ? theme.surfaceHover : "transparent" Row { anchors.fill: parent anchors.leftMargin: 8 + mqHeader.indent anchors.rightMargin: 4 spacing: 4 Text { id: mqLinkText width: parent.width - 26 height: parent.height text: mqHeader.label color: mqLinkArea.containsMouse ? theme.textPrimary : theme.textSecondary font.pixelSize: mqHeader.indent > 0 ? 12 : 13 font.weight: theme.weightMedium verticalAlignment: Text.AlignVCenter elide: Text.ElideRight MouseArea { id: mqLinkArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: mqHeader.linkClicked() } } Rectangle { width: 22 height: 22 radius: 4 visible: mqHeader.showChevron anchors.verticalCenter: parent.verticalCenter color: mqChevArea.containsMouse ? theme.surfaceElevated : "transparent" QbzIcon { name: mqHeader.expanded ? "chevron-down" : "chevron-right" width: 14 height: 14 anchors.centerIn: parent tintName: mqChevArea.containsMouse ? "textPrimary" : "muted" } MouseArea { id: mqChevArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: mqHeader.toggle() } } } } // A collapsible My QBZ tree ITEM: one mixtape/collection. Click opens its // detail (openCard); right-click offers "Hide from sidebar". component MyQbzItem: Rectangle { id: mqItem property string itemId: "" property string label: "" property bool isActive: false width: parent ? parent.width : 0 height: 28 radius: 6 color: (mqItemArea.containsMouse || mqItem.isActive) ? theme.surfaceHover : "transparent" Row { anchors.fill: parent // Indented past the section header's text so items read as children. anchors.leftMargin: 26 anchors.rightMargin: 8 spacing: 8 QbzIcon { name: "music" width: 13 height: 13 anchors.verticalCenter: parent.verticalCenter tintName: (mqItemArea.containsMouse || mqItem.isActive) ? "textPrimary" : "muted" } Text { width: parent.width - 21 height: parent.height text: mqItem.label color: (mqItemArea.containsMouse || mqItem.isActive) ? theme.textPrimary : theme.textSecondary font.pixelSize: 12 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } } MouseArea { id: mqItemArea anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: Qt.PointingHandCursor onClicked: function (mouse) { if (mouse.button === Qt.RightButton) { var lx = mqItem.width - myqbzRowMenu.menuWidth myqbzRowMenu.openForItem(mqItem.itemId, mqItem, Math.max(0, lx), 26) } else { QbzShell.myqbzOpenCard(mqItem.itemId) } } } } 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 // Offline-gated as before, PLUS the opt-in suppressor: with // the collapsible tree on and "Hide standard My QBZ menu // entry" checked, the flat My QBZ section row gives way to // the tree block mounted right below this Repeater. visible: !(modelData.qobuz && QbzSession.offline) && !(modelData.id === "myqbz" && root.hideStandardMyqbz) } } // ---- Collapsible My QBZ tree (opt-in) ---------------------- // Sits right under the four section rows (My QBZ is the last of // them), so its expanded tree reads as that section's contents. // Hidden on the mini rail and whenever the opt-in is off. The // parent "My QBZ" header collapses the whole block; each section // header links to its grid and collapses its own list. Column { id: myqbzTree visible: !root.mini && root.myqbzTreeOn width: parent.width spacing: 2 // Parent header TOOLBAR — Playlists-parity: branding icon + // title (a link to the My QBZ surface) on the LEFT; search / // new / more / collapse FLOATING on the right, their chevron // aligned to the playlist carets (rightMargin 4 → the 22px box's // 15px icon centres on width-15, same as the row carets and the // Mixtapes/Collections sub-chevrons). Item { width: parent.width height: 34 // Branding icon — user image, else the baked qbz-symbolic. Item { id: myqbzBrandIcon anchors.left: parent.left anchors.leftMargin: 8 anchors.verticalCenter: parent.verticalCenter width: 18 height: 18 Image { visible: navFlyout.brandingIconPath !== "" source: navFlyout.brandingIconPath width: 16 height: 16 anchors.centerIn: parent fillMode: Image.PreserveAspectFit mipmap: true } QbzIcon { visible: navFlyout.brandingIconPath === "" name: "qbz-symbolic" width: 16 height: 16 anchors.centerIn: parent tintName: "secondary" } } // Floating right-hand controls. Row { id: myqbzHeaderBtns anchors.right: parent.right anchors.rightMargin: 4 anchors.verticalCenter: parent.verticalCenter spacing: 4 // Search toggle. Rectangle { width: 22 height: 22 radius: 4 color: (myqbzSearchArea.containsMouse || root.myqbzSearchOpen) ? theme.surfaceHover : "transparent" QbzIcon { name: "search" width: 15 height: 15 anchors.centerIn: parent tintName: (myqbzSearchArea.containsMouse || root.myqbzSearchOpen) ? "textPrimary" : "muted" } MouseArea { id: myqbzSearchArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { root.myqbzSearchOpen = !root.myqbzSearchOpen if (!root.myqbzSearchOpen) { myqbzSearchEdit.text = "" root.myqbzSearch = "" } else { myqbzSearchEdit.forceActiveFocus() } } } } // New (+). Rectangle { width: 22 height: 22 radius: 4 color: myqbzPlusArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: "plus" width: 16 height: 16 anchors.centerIn: parent tintName: myqbzPlusArea.containsMouse ? "textPrimary" : "muted" } MouseArea { id: myqbzPlusArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: myqbzPlusMenu.openBelowLeft(myqbzPlusArea) } } // More (…) — sort + refresh. Rectangle { width: 22 height: 22 radius: 4 color: myqbzMoreArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: "ellipsis" width: 15 height: 15 anchors.centerIn: parent tintName: myqbzMoreArea.containsMouse ? "textPrimary" : "muted" } MouseArea { id: myqbzMoreArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: myqbzMoreMenu.openBelowLeft(myqbzMoreArea) } } // Collapse chevron (down = expanded). Rectangle { width: 22 height: 22 radius: 4 color: myqbzChevArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: root.myqbzCollapsed ? "chevron-right" : "chevron-down" width: 15 height: 15 anchors.centerIn: parent tintName: myqbzChevArea.containsMouse ? "textPrimary" : "muted" } MouseArea { id: myqbzChevArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzBridge.settingsBool("myqbz-collapsed", !root.myqbzCollapsed) } } } // Title — a link to the My QBZ surface (Collections). Fills // between the icon and the floating controls. Text { visible: !root.myqbzSearchOpen anchors.left: myqbzBrandIcon.right anchors.leftMargin: 8 anchors.right: myqbzHeaderBtns.left anchors.rightMargin: 6 anchors.verticalCenter: parent.verticalCenter text: navFlyout.brandingLabel color: myqbzTitleArea.containsMouse ? theme.textPrimary : theme.textSecondary font.pixelSize: 13 font.weight: theme.weightMedium elide: Text.ElideRight MouseArea { id: myqbzTitleArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzShell.navigateTo("collections") } } // Inline search field (filters the tree client-side). Rectangle { visible: root.myqbzSearchOpen anchors.left: myqbzBrandIcon.right anchors.leftMargin: 8 anchors.right: myqbzHeaderBtns.left anchors.rightMargin: 6 anchors.verticalCenter: parent.verticalCenter height: 22 radius: 4 color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) border.width: 1 border.color: theme.borderSubtle TextInput { QbzTextEditMenu { } id: myqbzSearchEdit anchors.fill: parent anchors.leftMargin: 6 color: theme.textPrimary font.pixelSize: 12 verticalAlignment: Text.AlignVCenter clip: true onTextEdited: root.myqbzSearch = text Text { visible: myqbzSearchEdit.text === "" anchors.fill: parent text: QbzSession.tr("Search My QBZ", QbzSession.trRev) color: theme.textMuted font.pixelSize: 12 verticalAlignment: Text.AlignVCenter } } } } // Mixtapes section (gated by its own opt-in). MyQbzHeader { visible: !root.myqbzCollapsed && root.myqbzShowMixtapes width: parent.width indent: 12 label: QbzSession.tr("Mixtapes", QbzSession.trRev) expanded: !root.myqbzMixtapesCollapsed onLinkClicked: QbzShell.navigateTo("mixtapes") onToggle: QbzBridge.settingsBool("myqbz-mixtapes-collapsed", !root.myqbzMixtapesCollapsed) } Repeater { model: (!root.myqbzCollapsed && root.myqbzShowMixtapes && !root.myqbzMixtapesCollapsed) ? root.myqbzMixtapes : [] delegate: MyQbzItem { required property var modelData width: myqbzTree.width itemId: String(modelData.id) label: String(modelData.name) } } Text { visible: !root.myqbzCollapsed && root.myqbzShowMixtapes && !root.myqbzMixtapesCollapsed && root.myqbzMixtapes.length === 0 width: parent.width leftPadding: 26 height: 22 text: QbzSession.tr("No mixtapes yet.", QbzSession.trRev) color: theme.textMuted font.pixelSize: 11 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } // Collections section (gated by its own opt-in). MyQbzHeader { visible: !root.myqbzCollapsed && root.myqbzShowCollections width: parent.width indent: 12 label: QbzSession.tr("Collections", QbzSession.trRev) expanded: !root.myqbzCollectionsCollapsed onLinkClicked: QbzShell.navigateTo("collections") onToggle: QbzBridge.settingsBool("myqbz-collections-collapsed", !root.myqbzCollectionsCollapsed) } Repeater { model: (!root.myqbzCollapsed && root.myqbzShowCollections && !root.myqbzCollectionsCollapsed) ? root.myqbzCollections : [] delegate: MyQbzItem { required property var modelData width: myqbzTree.width itemId: String(modelData.id) label: String(modelData.name) } } Text { visible: !root.myqbzCollapsed && root.myqbzShowCollections && !root.myqbzCollectionsCollapsed && root.myqbzCollections.length === 0 width: parent.width leftPadding: 26 height: 22 text: QbzSession.tr("No collections yet.", QbzSession.trRev) color: theme.textMuted font.pixelSize: 11 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } } // Purchases — LAST in the block and the only non-section row here // (Sidebar.slint:794-805). Opt-in, hidden while offline (the // surface needs the Qobuz session), and ACTIVE on both purchase // routes so the highlight survives the click into a purchased // album's detail. // // The click only navigates. Entering the list is the VIEW's job: // `openList()` pairs with `leave()` on the view's own // mount/unmount (§G.1), and Back reaches this route without ever // passing through this row — a row that pre-loaded would leave the // history path unloaded and double-fetch the direct path. DirectRow { visible: root.purchasesVisible iconName: "shopping-bag" label: QbzSession.tr("Purchases", QbzSession.trRev) active: QbzShell.currentView === "purchases" || QbzShell.currentView === "purchase-album" onActivated: QbzShell.navigateTo("purchases") } // Purchases DOWNLOADING right now. A download is process-wide // and survives leaving its page (purchases_qt's store); this is // how the user finds it again. Expanded: one row per album with // a thin progress bar. Mini: one ring for the whole set, with // the per-album detail in its hover bubble. Absent while idle. Item { id: dlBlock readonly property var rows: root.activeDownloads visible: root.purchasesVisible && rows.length > 0 width: parent.width height: !visible ? 0 : (root.mini ? theme.sidebarMiniRow : dlCol.implicitHeight) Column { id: dlCol visible: !root.mini width: parent.width spacing: 2 Repeater { model: root.mini ? [] : dlBlock.rows delegate: Rectangle { id: dlRow required property var modelData width: parent ? parent.width : 0 height: 34 radius: 6 color: dlArea.containsMouse ? theme.surfaceHover : "transparent" Column { anchors.left: parent.left anchors.right: parent.right anchors.leftMargin: 8 anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter spacing: 4 Row { width: parent.width spacing: 6 QbzIcon { name: "cloud-download" width: 14 height: 14 anchors.verticalCenter: parent.verticalCenter tintName: "accent" } Text { width: parent.width - 20 - 34 text: dlRow.modelData.title || QbzSession.tr("Purchases", QbzSession.trRev) color: theme.textSecondary font.pixelSize: 12 elide: Text.ElideRight anchors.verticalCenter: parent.verticalCenter } Text { width: 28 text: (dlRow.modelData.percent || 0) + "%" color: theme.textMuted font.pixelSize: 11 horizontalAlignment: Text.AlignRight anchors.verticalCenter: parent.verticalCenter } } Rectangle { width: parent.width height: 3 radius: 1.5 color: theme.borderSubtle Rectangle { width: parent.width * Math.max(0, Math.min(1, (dlRow.modelData.percent || 0) / 100)) height: parent.height radius: parent.radius color: theme.accent } } } MouseArea { id: dlArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { QbzPurchases.openAlbum(dlRow.modelData.id || "") QbzShell.navigateTo("purchase-album") } } } } } // Mini: the ring, with the detail on hover. Item { visible: root.mini width: parent.width height: parent.height QbzProgressRing { anchors.centerIn: parent size: 18 value: root.activeDownloadsOverall } MouseArea { id: ringArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzShell.navigateTo("purchases") } ToolTip { visible: ringArea.containsMouse delay: 0 timeout: -1 text: root.activeDownloadsSummary x: parent.width + 8 y: Math.round((parent.height - height) / 2) contentItem: Text { text: root.activeDownloadsSummary color: theme.textPrimary font.pixelSize: 11 font.weight: theme.weightMedium leftPadding: 9 rightPadding: 9 topPadding: 6 bottomPadding: 6 } background: Rectangle { color: theme.surfaceElevated radius: 4 border.width: 1 border.color: theme.borderMuted } } } } } // 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.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) border.width: 1 border.color: theme.borderSubtle TextInput { QbzTextEditMenu { } 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 (+) — opens controls/PlaylistCreateModal.qml, the // port of primitives/CreatePlaylistModal.slint. // // IT USED TO BE A ONE-CALL SHORTCUT (`QbzShell.createPlaylist()`) // that created a playlist named "New Playlist" and navigated to // it. That shortcut had no way to set the name, the description, // the folder or the public flag — and, the reason it counts as a // regression rather than a rough edge, no way to set OFFLINE-ONLY. // It derived that from connectivity, so an online user could not // create a local playlist at all. // // NOT offline-gated, and that is deliberate: the modal's own D8 // arm locks the offline-only toggle ON while the app is offline // and creates a LOCAL playlist. Dimming this row would take the // ONE way an account-less user makes a playlist away from exactly // the state they live in. 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: QbzPlaylistEdit.openCreate() } } // 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 // Slint puts this panel's left edge at `toolbar.width - // 40px`. Resolved: the open sidebar's content column is // 240 - 2*16 = 208 wide and the "..." button occupies // 160…182 of it, so 208 - 40 = 168 is the button's own // x + 8. This Popup is parented to the BUTTON, not to the // row, so the 1:1 value is x: 8 — the old `parent.width + // 18` (= 40) put it 32px too far right. x: 8 y: 26 width: 200 padding: 6 closePolicy: Popup.CloseOnPressOutside background: Rectangle { // OPAQUE UNDER AMBIENT, unlike this root. Sidebar.slint // :920-922 gives the panel the sidebar's own ambient // treatment (surface-card @50%), and it was ported 1:1 — // which made this the ONE menu surface in the app that // goes translucent while the dynamic background is on. // Every other menu/flyout (QbzContextMenu and therefore // CardMenu / SidebarRowMenu / ViewModeMenu / the audio // flyout, plus NavFlyout, SidebarFolderFlyout, // QconnectFlyout, GenreFilterPopup, LocalFilterPopup) // paints a flat opaque surface. A menu is TEXT the user // is reading against artwork that moves under it, not // chrome, so the outlier lost: owner ruling 2026-08-19, // "la lectura se complica con la transparencia". 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 / Refresh — // all four LIVE (the Refresh row was missing from this // port entirely and is restored here). // // `on` is the enabled flag and it drives everything at // once: the hover fill, the pointer cursor, the glyph // tint, the 0.4 dim AND whether the MouseArea exists. // There is no way to light one row and leave another // inert without it. // // Import is the only row that can be off, and only // while offline — importing a playlist is Qobuz-only // work. // // REFRESH STAYS LIT OFFLINE (reversed 2026-07-31 on the // owner's smoke). The earlier decision dimmed it // because QbzShell.reloadSidebar() called // crate::reload_sidebar(), which early-returns offline // — a lit row would have been a no-op, and a control // that renders and no-ops is a defect. The bridge now // calls crate::reload_sidebar_including_local() // instead, which offline re-reads folders, folder // membership, the hidden set and the LOCAL playlists // from library.db and republishes (preserving the // cached Qobuz set). That is a real refresh, and it is // precisely the tree an account-less user owns — the // owner hit an empty offline sidebar with no way to // rebuild it. // // Every arm closes the menu FIRST. Slint's PopupWindow // dismisses on any click, including inside itself, // which is why its own items never call close(); Qt's // CloseOnPressOutside does the opposite. This is the // single most common silent divergence in this seam. Repeater { model: [ { "icon": "folder-plus", "label": QbzSession.tr("New folder", QbzSession.trRev), "act": "newFolder", "on": true }, { "icon": "library-big", "label": QbzSession.tr("Manage playlists", QbzSession.trRev), "act": "manage", "on": true }, // LIVE. The importer landed with this seam // (src/playlist_import_bridge.rs + // controls/PlaylistImportModal.qml), so contract // §10 Q2 resolves to "land the importer" and // there is no `typeof QbzPlaylistImport` guard // here: the row is a real row, dimmed only // while offline — the only row that dims. { "icon": "import", "label": QbzSession.tr("Import", QbzSession.trRev), "act": "import", "on": !QbzSession.offline }, { "icon": "refresh-cw", "label": QbzSession.tr("Refresh", QbzSession.trRev), "act": "refresh", "on": true }, ] delegate: Rectangle { id: actRow required property var modelData readonly property bool rowEnabled: modelData.on === true width: parent ? parent.width : 0 height: 30 radius: 5 color: (actRow.rowEnabled && actArea.containsMouse) ? theme.surfaceHover : "transparent" opacity: actRow.rowEnabled ? 1.0 : 0.4 Row { anchors.fill: parent anchors.leftMargin: 8 anchors.rightMargin: 8 spacing: 8 QbzIcon { name: actRow.modelData.icon width: 14 height: 14 anchors.verticalCenter: parent.verticalCenter tintName: actRow.rowEnabled ? "secondary" : "muted" } Text { width: parent.width - 22 height: parent.height text: actRow.modelData.label color: actRow.rowEnabled ? theme.textSecondary : theme.textMuted font.pixelSize: 13 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } } MouseArea { id: actArea anchors.fill: parent enabled: actRow.rowEnabled hoverEnabled: actRow.rowEnabled cursorShape: actRow.rowEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: { sortMenu.close() var a = actRow.modelData.act if (a === "newFolder") QbzFolderEdit.openCreate() else if (a === "manage") QbzPlaylistManager.navigate() else if (a === "import") QbzPlaylistImport.open() else if (a === "refresh") QbzShell.reloadSidebar() } } } } } } } // 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: QbzBridge.settingsBool("sidebar-playlists-collapsed", !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. These used // to be the Slint literals "#3fae6a1a" / // "#3fae6a4d" copied verbatim, which is a real // colour bug: Slint's 8-digit form is #RRGGBBAA and // Qt's is #AARRGGBB, so the drop target rendered // olive-brown at 25% alpha instead of success green // at 15% / 35%. The tokens carry the converted // values (theme.successBg = #263fae6a). color: dropHot ? theme.successBg : ((rowArea.containsMouse || isActive) ? theme.surfaceHover : "transparent") border.width: dropHot ? 1 : 0 border.color: theme.successBorder 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" } // Playlist micro-collage (or list-music // glyph) — the port of // primitives/SidebarPlaylistCollage.slint: // a 20px box, 3px radius, surface-elevated // backing, 1px seam. // // TWO arms, not one. The reference splits // on the cover count (.slint:27-68): // 1-3 covers -> ONE full-bleed cover, 4+ -> // the 2x2 grid. This used to be an // unconditional 2x2 of hard-coded 10x10 // tiles with no seam, so a playlist with a // single cover drew one 10x10 tile in the // top-left quadrant and left three quarters // of the box empty. // // Covers stay on the host's already-resolved // `root.coverMap` (filled by the ONE // `sidebarArtworkWindow` dispatch in // parseEntries) — this is not a second // artwork seam. Item { id: collage visible: useCollage width: 20 height: 20 x: Math.round((parent.width - width) / 2) y: Math.round((parent.height - height) / 2) // `n`, straight off SidebarEntry.cover-count // (.slint:24). sidebar_qt.rs already // de-duplicates and caps `covers` at 4 // (SidebarEntry::covers), so this is 0..4 // and no dedup pass is owed here. readonly property int n: useCollage ? plRow.modelData.covers.length : 0 // .slint:25 `gap: 1px`. NOT the mosaic // card's 2px seam — the micro-collage is // its own component with its own number. readonly property real gap: 1 // Backing surface. A Rectangle's OWN // rounded fill paints correctly; only its // CHILDREN need the mask below, which is // exactly the distinction the old code // missed. Rectangle { anchors.fill: parent radius: 3 color: theme.surfaceElevated } // The rounded outer shape as a mask // texture. `clip: true` in Qt Quick is a // RECTANGULAR scissor and IGNORES // `radius`, which is why the old // `Rectangle { radius: 3; clip: true }` // shipped square collages: the fill was // round, the four covers painted on top // of it were not (owner report). The // documented fix is the `layer.enabled` + // `MultiEffect { maskEnabled }` idiom — // same mechanism, same constants, as // cards/PlaylistCollage.qml:264-305 and // theme/RoundedImage.qml. Item { id: collageMask anchors.fill: parent visible: false // A layer renders even when its item // is invisible — that IS the mask // idiom — so the gate has to be // `useCollage`, not `visible`. // PlaylistCollage.qml gates its own // two layers on `tiles.length > 0` // for the same reason, and it matters // MORE here: this delegate is inside // a Repeater over the whole playlist // tree, so an ungated pair of layers // would cost two FBOs on every folder // row and every glyph row too. layer.enabled: useCollage && !root._noShaders layer.smooth: true Rectangle { anchors.fill: parent radius: 3 color: "#ffffff" } } // ONE layer + ONE mask for the whole // collage, not one per tile. `clip` stays // on the container because // PreserveAspectCrop paints OUTSIDE its // item's bounds unless clipped — it just // cannot round on its own. Item { anchors.fill: parent clip: true layer.enabled: useCollage && !root._noShaders layer.smooth: true layer.effect: MultiEffect { maskEnabled: true maskSource: collageMask // 0.5 / 1.0 — MEASURED; the full // table is in // theme/RoundedImage.qml. Qt's // mask is a smoothstep CENTRED on // maskThresholdMin of width // maskSpreadAtMin, so this is the // only pair that maps mask alpha // 0..1 onto 0..1. (0.0, 0.0) // collapses it to step() and the // corners come back HARD; (0.0, // > 0) clamps everything opaque // and disables the mask outright. // Do not "restore the defaults". maskThresholdMin: 0.5 maskSpreadAtMin: 1.0 } // 1-3 covers -> a single full-bleed // cover (.slint:28-33). `image-fit: // cover` = PreserveAspectCrop. Image { anchors.fill: parent visible: collage.n >= 1 && collage.n < 4 // Empty out of arm so the hidden // branch holds no texture. Reading // `visible` back gives the // EFFECTIVE visibility, so this // also drops the texture for a row // the mini rail collapses — and // re-binds when it comes back. source: visible ? (root.coverMap[plRow.modelData.covers[0]] || "") : "" fillMode: Image.PreserveAspectCrop asynchronous: true } // 4+ covers -> a 2x2 grid of // (size - gap) / 2 tiles with the 1px // seam (.slint:36-68). Grid `spacing` // IS that seam: 9.5 + 1 + 9.5 = 20. Grid { anchors.fill: parent columns: 2 spacing: collage.gap Repeater { // Empty below four — the arm // is not merely hidden, it // holds no tiles and no // textures. model: collage.n >= 4 ? plRow.modelData.covers.slice(0, 4) : [] delegate: Image { required property string modelData width: (collage.width - collage.gap) / 2 height: (collage.height - collage.gap) / 2 source: root.coverMap[modelData] || "" fillMode: Image.PreserveAspectCrop asynchronous: true // Per-tile scissor: a crop // fit overflows its own // bounds, and the // container's clip is 20px // wide — without this a // wide cover bleeds into // the neighbouring cell // instead of being cropped // to it (Slint's // `image-fit: cover` // clips per element). clip: 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) // The local mark is a sibling AFTER // this Text, so its width has to come // out of the name's reserve or the // glyph lands past the row's clip. - (modelData.isLocal === true ? 20 : 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 } // LOCAL playlist mark. A local playlist has no // Qobuz side at all — this is how the row says // so, and it is the only visual difference // between the two kinds in the list. QbzIcon { visible: !root.mini && modelData.isLocal === true name: "hard-drive" width: 12 height: 12 anchors.verticalCenter: parent.verticalCenter tintName: "muted" } // 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 // Right-press anywhere on the row opens its // context menu (Sidebar.slint:389-397). The old // hover "..." button was removed upstream // because the scrollbar overlapped it and ate // the click — do not reintroduce it. acceptedButtons: Qt.LeftButton | Qt.RightButton // 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: function (mouse) { // Slint drops the bubble on click so it // does not linger behind the flyout. root.hideMiniTip(plRow.modelData.id) if (mouse.button === Qt.RightButton) { // SUPPRESSED ON THE RAIL. Slint anchors // this menu at `row.width - 210px`, // which on a 34px rail row is deeply // negative; Qt's viewport clamp would // silently drop it at x=8, which is not // the reference's placement, just the // clamp. if (root.mini) return var lx = plRow.width - rowMenu.menuWidth if (isFolder) rowMenu.openForFolder(plRow.modelData, plRow, lx, 28) else rowMenu.openForPlaylist(plRow.modelData, plRow, lx, 28) return } 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 } } } }