// Top header bar — QML port of crates/qbz-ui/ui/shell/HeaderBar.slint. // // Left: the three "sacred" nav buttons (sidebar cycle, back, forward) and, // after them, the section nav in whichever form the placement prefs ask for // (HeaderBar.slint:858 / :974 — the two blocks are mutually exclusive): // // navInSidebar ON -> the sections live in Sidebar.qml. The header shows // the COMPACT icon nav only while the sidebar is fully // closed (state 2), plus the separator + playlists // flyout button, so nothing is unreachable. // navInSidebar OFF -> the sections live HERE. Full text tabs while the // sidebar is not fully closed and navHeaderCompact is // OFF; the compact icon form when navHeaderCompact is // ON, or whenever the sidebar is fully closed. // // The search field gives up 60px whenever the nav is in the header, and // re-centers with a 220ms animation (HeaderBar.slint:569). // Center: the search field, absolutely centered on the window. It owns the // live-search input path: the >= 2-char gate and the dismiss below it, the // arrow/Enter/Escape rules, the ↵ affordance and clearSearch(). The 220ms // debounce is NOT here — it lives in Rust (search_qt::live), so the panel // opens on the first keystroke instead of 220ms after the last one. The // dropdown itself is shell/Cortinilla.qml. // Right: the tri-state offline status badge with its flyout (recovery // "Sign in" wired to QbzSession.recoveryLogin) and the app menu (user block + // Settings + Keyboard Shortcuts + Documentation + What's New + About QBZ + // Report an Issue + Log Out + Close). ONE row is still missing against the // Slint menu: Open Music Link, which needs the LinkResolver this port does not // have yet. // // The custom window chrome IS built (the note claiming it was skipped was a // fossil): the header itself is the drag surface (`startSystemMove()` below), // and the drawn min/max/close WindowControls Row renders whenever // `use_system_title_bar` is OFF. Native decorations are the OTHER arm of that // same pref, not the port's ceiling. Close routes through Main.qml's // closeOrHide, so it honours close-to-tray like every other exit. // // FILE LENGTH (>500): this is ONE bar with one placement state machine — // `navInSidebar` x `navHeaderCompact` x the three sidebar widths decide which // of the header's forms is mounted, and every block below reads that same // state. The parts that are self-contained have already been extracted and are // SHARED with the sidebar rather than duplicated: the section dropdown // (shell/NavFlyout.qml), its leading glyph (shell/NavSectionGlyph.qml), the // nav buttons (controls/QbzNavButton.qml) and the hover bubble // (controls/QbzTooltip.qml, mounted once by AppShell). What remains is the // bar's own layout — left controls, the absolutely-centred search, the status // badge, the window controls and the app menu — each a positional slot of this // single row, so splitting them would hand every half the same placement // properties to keep in sync while none of them is reusable anywhere else. // The closed-sidebar playlists flyout is the one candidate left: it is // self-contained, but it reads `plBtn` for its anchor and the sidebar's shared // search query, so it moves the day a second surface needs it. import QtQuick import QtQuick.Controls import com.blitzfc.qbz import "../controls" import "../theme" Rectangle { id: root // surface-card @ 0.5 while the ambient background is active (phase 14, // HeaderBar.slint's with-alpha(app-background-surface-alpha)). color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard readonly property bool ambientOn: theme.ambientOn /// Raised by the app menu's "Report an issue" row; AppShell owns the modal. signal reportIssueRequested() // The host ApplicationWindow (custom chrome); null in previews. property var hostWindow: null QbzTheme { id: theme } // --- Custom-chrome gating, 1:1 with HeaderBar.slint:594-609 ----------- // Three prefs decide what the header owns. They were all persisted and // all ignored until 2026-08-04. // // chromeDragEnabled — the header IS the title bar: press-to-move and // double-click-to-maximize. Off under the system // title bar (the native chrome owns it) and off // when the user asked for no title bar at all. // chromeControls — additionally draw min/max/close. Never on macOS: // there the NATIVE traffic lights float over this // bar (see chromeLeftInset) and a drawn cluster // would be a second set of buttons. readonly property bool chromeDragEnabled: !QbzShell.systemTitleBar && !QbzShell.hideTitleBar readonly property bool chromeControls: root.chromeDragEnabled && !QbzShell.isMacos && QbzShell.showWindowControls readonly property bool wcOnLeft: QbzShell.wcOnLeft // 3 x 34px buttons + 2 x 2px spacing (WindowControls.slint). readonly property int wcClusterWidth: 106 // Left inset for the leading nav: the native traffic-light band on macOS, // or the drawn cluster when it sits left (HeaderBar.slint:605-607). // // On macOS this now ASKS Qt instead of hard-coding 78. With // `ExpandedClientAreaHint` the client area covers the region the titlebar // controls occupy, and `SafeArea.margins` reports exactly how much of it // is unsafe — the traffic-light band, in the window's real metrics rather // than the reference's estimate. The 78 stays only as the floor for the // case where the margin reads 0 (nothing to lose, and it is the number // the Slint build has used all along). readonly property int chromeLeftInset: QbzShell.isMacos && !QbzShell.systemTitleBar ? Math.max(root.SafeArea.margins.left, 78) : (root.chromeControls && root.wcOnLeft ? root.wcClusterWidth + 6 : 0) // The section-nav dropdown (catalog + panel + open/close behaviour). // Zero-size and NOT inside any Row — its origin must stay this root's // origin, because the trigger coordinates are mapped into it. The panel // itself renders in the window overlay, so it is not clipped by the 42px // header (Slint solves the same problem by mounting HeaderMenuOverlay as // the last child of the AppShell root). NavFlyout { id: navFlyout } // Custom-chrome drag surface (declared FIRST so every interactive // element above wins hit-testing): press-and-move starts the system // move; double-click toggles maximize. The system grab starts only // after a real movement so plain clicks/double-clicks still work. MouseArea { anchors.fill: parent // Inert under the system titlebar (the native chrome owns // drag/double-click) and when the title bar is hidden outright — // HeaderBar.slint's `chrome-drag-enabled`. enabled: root.chromeDragEnabled property bool dragStarted: false onPressed: dragStarted = false onPositionChanged: { if (pressed && !dragStarted && root.hostWindow) { dragStarted = true root.hostWindow.startSystemMove() } } onDoubleClicked: { if (root.hostWindow) { root.hostWindow.visibility = root.hostWindow.visibility === Window.Maximized ? Window.Windowed : Window.Maximized } } } // Slint OfflineState.badge-state: 0 hidden / 1 hard offline / 2 manual / // 3 logged out (wins over the others). readonly property int badgeState: QbzSession.offlineSession ? 3 : QbzSession.offlineMode === 2 ? 2 : QbzSession.offlineMode === 1 ? 1 : 0 // --- Section nav (shared by both header forms) ------------------------ // The catalog (sections, their entries, icons and order) and the flyout // panel itself now live in ONE place, NavFlyout.qml — mounted above as // `navFlyout`, and mounted the same way by Sidebar.qml. Slint duplicates // the same literals three times (Sidebar.slint:724, HeaderBar.slint:858 // full tabs, :974 compact buttons); the port does not. // // Triggers no longer navigate: like their Slint counterparts they OPEN the // section dropdown (on hover and on click), and the ENTRIES navigate. readonly property var navSections: navFlyout.sections /// The settings snapshot, try-parsed. `settingsJson` is `"{}"` before the /// first publish, and a bare `JSON.parse` throwing on that frame would take /// the whole header down rather than just one entry. readonly property var settingsDoc: { try { return JSON.parse(QbzBridge.settingsJson) } catch (e) { return ({}) } } /// Purchases in the TITLE BAR — the third row of the §7.1 truth table. /// /// This is the EXACT COMPLEMENT of `Sidebar.qml`'s `purchasesVisible`, and /// it has to be, because that property WITHDRAWS the sidebar row for this /// configuration. Until this existed the entry was withdrawn from one host /// and offered by none: with `show_purchases` and `nav_tb_purchases` both on /// and a custom title bar, Purchases simply had no way in. /// /// `nav_tb_purchases` only RELOCATES; `show_purchases` is the master gate. /// Under system chrome or with no title bar there is nowhere to relocate TO, /// so the sidebar keeps it and this stays false. readonly property bool purchasesInHeader: root.settingsDoc.showPurchases === true && !QbzSession.offline && root.settingsDoc.navTbPurchases === true && !QbzShell.systemTitleBar && !QbzShell.hideTitleBar // Highlighted section — derived from the live view (see NavFlyout), OR'd // in the triggers with "my menu is open" (Slint highlights off // HeaderMenuState.open-index). readonly property string activeNav: navFlyout.activeSection // Which of the two header forms is mounted (mutually exclusive, and both // off while the nav lives in the sidebar and the sidebar is not closed). readonly property bool headerTabsOn: !QbzShell.navInSidebar && QbzShell.sidebarState !== 2 && !QbzShell.navHeaderCompact readonly property bool headerCompactOn: QbzShell.sidebarState === 2 || (!QbzShell.navInSidebar && QbzShell.navHeaderCompact) // Full text tab (HeaderBar.slint NavTab): 30px tall, radius sm, icon 14 // (dropped under 1140px), label 11px — semibold + elevated fill when the // section is the current view. /// The title-bar Purchases entry. Deliberately NOT a `NavTab`: that /// component is built around a section object and its click opens the /// flyout, while Purchases navigates straight to its route. component PurchaseTab: Rectangle { id: purchaseTab property bool showIcon: true /// Icon-only, to sit beside the compact section buttons. property bool compact: false readonly property bool isActive: QbzShell.currentView === "purchases" || QbzShell.currentView === "purchase-album" height: 30 width: purchaseRow.implicitWidth radius: theme.radiusSm color: purchaseTab.isActive ? theme.surfaceElevated : purchaseArea.containsMouse ? theme.surfaceHover : "transparent" Row { id: purchaseRow height: parent.height leftPadding: purchaseTab.compact ? 8 : (purchaseTab.showIcon ? 9 : 11) rightPadding: purchaseTab.compact ? 8 : 11 spacing: 6 QbzIcon { visible: purchaseTab.showIcon anchors.verticalCenter: parent.verticalCenter name: "shopping-bag" width: 16 height: 16 tintName: purchaseTab.isActive ? "primary" : "muted" } Text { visible: !purchaseTab.compact anchors.verticalCenter: parent.verticalCenter text: QbzSession.tr("Purchases", QbzSession.trRev) color: purchaseTab.isActive ? theme.textPrimary : theme.textMuted font.pixelSize: theme.fontLegal font.weight: theme.weightMedium } } MouseArea { id: purchaseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor // Never touches `navFlyout.triggerHovered`. Setting it while another // section's panel is open marks the flyout as hovered and halts its // 1 s idle-close, leaving that panel hanging open under the pointer. onClicked: QbzShell.navigateTo("purchases") } } component NavTab: Rectangle { id: navTab property var section: null property bool showIcon: true readonly property bool isActive: section && (root.activeNav === section.id || navFlyout.openId === section.id) readonly property bool isEnabled: section && section.enabled height: 30 width: tabRow.implicitWidth radius: theme.radiusSm opacity: isEnabled ? 1.0 : 0.5 color: isActive ? theme.surfaceElevated : (tabArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent" Row { id: tabRow height: parent.height leftPadding: navTab.showIcon ? 9 : 11 rightPadding: 11 spacing: 6 // Baked glyph, or the section's own raw image when it carries one // (My QBZ branding) — see shell/NavSectionGlyph.qml. NavSectionGlyph { visible: navTab.showIcon section: navTab.section size: 14 // Collapses to zero width under the 1140px breakpoint so the // Row reclaims the space (the height stays 14 via `size`). width: navTab.showIcon ? 14 : 0 anchors.verticalCenter: parent.verticalCenter // Both arms are THEME tokens (the tab sits on surface-elevated // / surface-hover): "textPrimary", never the fixed-white // "primary" bake, or the active tab is white-on-white on every // light theme. See the vocabulary in QbzIcon.qml. tintName: navTab.isActive ? "textPrimary" : "secondary" } Text { height: parent.height text: navTab.section ? navTab.section.label : "" color: theme.textPrimary font.pixelSize: 11 font.weight: navTab.isActive ? theme.weightSemibold : theme.weightRegular verticalAlignment: Text.AlignVCenter } } MouseArea { id: tabArea anchors.fill: parent enabled: navTab.isEnabled hoverEnabled: navTab.isEnabled cursorShape: Qt.PointingHandCursor // Full text tabs name their section already, so the dropdown gets // no title row (HeaderBar.slint:167 sets title: ""). onClicked: navFlyout.openUnder(navTab, navTab.section, false) onContainsMouseChanged: { if (containsMouse) { // Hover-to-open, and hovering a DIFFERENT tab overwrites // the open menu — instant switch, single-open by design. navFlyout.triggerHovered = true navFlyout.openUnder(navTab, navTab.section, false) } else if (navTab.section && navFlyout.openId === navTab.section.id) { // Only the trigger owning the open menu clears the flag // (avoids a leave/enter race between adjacent tabs). navFlyout.triggerHovered = false } } } } // Compact icon-only button (HeaderBar.slint CompactNavBtn): 30x30, icon // 16, elevated fill + primary tint while its section is current. component CompactNavBtn: Rectangle { id: cnb property var section: null readonly property bool isActive: section && (root.activeNav === section.id || navFlyout.openId === section.id) readonly property bool isEnabled: section && section.enabled width: 30 height: 30 radius: theme.radiusSm opacity: isEnabled ? 1.0 : 0.5 color: isActive ? theme.surfaceElevated : (cnbArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent" // Baked glyph, or the section's own raw image when it carries one // (My QBZ branding) — see shell/NavSectionGlyph.qml. NavSectionGlyph { section: cnb.section size: 16 anchors.centerIn: parent tintName: cnb.isActive ? "textPrimary" : "secondary" } MouseArea { id: cnbArea anchors.fill: parent enabled: cnb.isEnabled hoverEnabled: cnb.isEnabled cursorShape: Qt.PointingHandCursor // Icon-only buttons do NOT name their section, so their dropdown // is headed by the section name + hairline (HeaderBar.slint:223). onClicked: navFlyout.openUnder(cnb, cnb.section, true) onContainsMouseChanged: { if (containsMouse) { navFlyout.triggerHovered = true navFlyout.openUnder(cnb, cnb.section, true) } else if (cnb.section && navFlyout.openId === cnb.section.id) { navFlyout.triggerHovered = false } } } } // --- Left controls --------------------------------------------------- Row { id: leftControls // HeaderBar.slint:825 — `Spacing.md + chrome-left-inset`, so the nav // clears the traffic lights (macOS) or the left-placed cluster. x: theme.spacingMd + root.chromeLeftInset y: (root.height - height) / 2 height: 36 spacing: 6 QbzNavButton { name: "panel-left" anchors.verticalCenter: parent.verticalCenter onClicked: QbzShell.cycleSidebar() } QbzNavButton { name: "chevron-left" anchors.verticalCenter: parent.verticalCenter btnEnabled: QbzShell.canBack onClicked: QbzShell.navigateBack() } QbzNavButton { name: "chevron-right" anchors.verticalCenter: parent.verticalCenter btnEnabled: QbzShell.canForward onClicked: QbzShell.navigateForward() } // Full section nav (text tabs) — nav in the header, sidebar not fully // closed, compact form OFF. Sits AFTER the three sacred buttons so it // can never overlap them (HeaderBar.slint:853). Row { visible: root.headerTabsOn height: parent.height spacing: 2 Item { width: 6; height: 1 } Repeater { model: root.navSections delegate: NavTab { required property var modelData section: modelData // Qobuz-only sections are HIDDEN while offline (ADR-010 // mount-site gating, not rendered-disabled). visible: !(modelData.qobuz && QbzSession.offline) // Tabs drop their icons under the first breakpoint. showIcon: root.width >= 1140 anchors.verticalCenter: parent.verticalCenter } } // Purchases is a DIRECT destination, not a section: it has no // flyout and no entries, so it cannot ride the Repeater above — // NavTab's click opens a panel. Hence its own tab. PurchaseTab { visible: root.purchasesInHeader showIcon: root.width >= 1140 anchors.verticalCenter: parent.verticalCenter } } // Compact section nav — while the sidebar is fully closed (so the // sections stay reachable), or always when the nav is in the header // and "Compact header navigation" is ON (HeaderBar.slint:974). Row { visible: root.headerCompactOn height: parent.height spacing: 2 Item { width: 6; height: 1 } Repeater { model: root.navSections delegate: CompactNavBtn { required property var modelData section: modelData visible: !(modelData.qobuz && QbzSession.offline) anchors.verticalCenter: parent.verticalCenter } } // The same direct entry, icon-only, so the compact form is not the // one configuration where Purchases disappears again. PurchaseTab { visible: root.purchasesInHeader compact: true anchors.verticalCenter: parent.verticalCenter } // Thin separator + the playlists flyout button — ONLY while the // sidebar is really closed. In the opt-in always-compact mode the // sidebar list is still on screen, so the button stays away // (HeaderBar.slint:1078). Rectangle { visible: QbzShell.sidebarState === 2 width: 1 height: 18 anchors.verticalCenter: parent.verticalCenter color: theme.borderSubtle } Rectangle { id: plBtn visible: QbzShell.sidebarState === 2 width: 30 height: 30 radius: theme.radiusSm anchors.verticalCenter: parent.verticalCenter color: plPopup.opened ? theme.surfaceElevated : plBtnArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: "list-music" width: 16 height: 16 anchors.centerIn: parent tintName: plPopup.opened ? "textPrimary" : "secondary" } MouseArea { id: plBtnArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: plPopup.open() } // Closed-sidebar playlists flyout (SidebarPlaylistsPopup): // 248px wide, a search header over the SAME Rust-side filter // the sidebar uses, then the flat entries (folders toggle in // place, playlists open and close the flyout). Six rows // visible, the rest scroll, then Slint's two footer rows — // Import and Manage playlists. // // Those two call the IDENTICAL invokables the sidebar "..." // menu's rows do, and both surfaces close themselves and clear // the SHARED search query before acting (this popup's onClosed // already does the clearing). Wiring only one of them would // re-create the gap this comment used to describe: the flyout // is the only playlists surface a user with a closed sidebar // has, so an entry point missing here is missing outright. Popup { id: plPopup y: plBtn.height + 6 width: 248 padding: 4 closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape readonly property var entries: JSON.parse(QbzShell.sidebarJson) onClosed: { // Clear the shared filter so it doesn't silently scope // the expanded sidebar's list later. plSearch.text = "" QbzShell.sidebarSearch("") } background: Rectangle { color: theme.surfaceMain radius: theme.radiusSm border.width: 1 border.color: theme.borderMuted } contentItem: Column { width: parent.width spacing: 0 // Search header (34px band, 26px field). Item { width: parent.width height: 34 Rectangle { anchors.centerIn: parent width: parent.width - 16 height: 26 radius: 4 color: theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle TextInput { id: plSearch anchors.fill: parent anchors.leftMargin: 6 anchors.rightMargin: 6 color: theme.textPrimary font.pixelSize: 12 verticalAlignment: Text.AlignVCenter clip: true onTextEdited: QbzShell.sidebarSearch(text) Text { visible: plSearch.text === "" anchors.fill: parent text: QbzSession.tr("Search playlists", QbzSession.trRev) color: theme.textMuted font.pixelSize: 12 verticalAlignment: Text.AlignVCenter } } } } // Entries — 32px rows, six visible then scroll. Flickable { width: parent.width height: Math.max(32, Math.min(plPopup.entries.length, 6) * 32) clip: true contentWidth: width contentHeight: plList.implicitHeight boundsBehavior: Flickable.StopAtBounds Column { id: plList width: parent.width Repeater { model: plPopup.entries delegate: Rectangle { required property var modelData readonly property bool isFolder: modelData.kind === "folder" width: plList.width height: 32 radius: 5 color: ppArea.containsMouse ? theme.surfaceHover : "transparent" Row { anchors.fill: parent anchors.leftMargin: modelData.indent ? 26 : 12 anchors.rightMargin: 12 spacing: 8 QbzIcon { name: isFolder ? (modelData.expanded ? "folder-open" : "folder") : "list-music" width: 14 height: 14 anchors.verticalCenter: parent.verticalCenter tintName: isFolder ? "accent" : "muted" } Text { width: parent.width - 22 - (ppCount.visible ? ppCount.implicitWidth + 8 : 0) height: parent.height text: modelData.name color: isFolder ? theme.textPrimary : theme.textSecondary font.pixelSize: 13 font.weight: isFolder ? theme.weightSemibold : theme.weightRegular verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } Text { id: ppCount visible: isFolder && modelData.count > 0 height: parent.height text: modelData.count color: theme.textMuted font.pixelSize: 11 verticalAlignment: Text.AlignVCenter } } MouseArea { id: ppArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { if (isFolder) { QbzShell.sidebarToggleFolder(modelData.id) } else { QbzBridge.openPlaylist(modelData.id) plPopup.close() } } } } } // Empty state — the flyout would otherwise be a // blank 32px band with no explanation. Text { visible: plPopup.entries.length === 0 width: plList.width height: 32 leftPadding: 12 text: QbzSession.tr("No playlists yet.", QbzSession.trRev) color: theme.textMuted font.pixelSize: 12 verticalAlignment: Text.AlignVCenter } } } // ---- Footer (SidebarPlaylistsPopup.slint:189-269) --- Rectangle { width: parent.width height: 1 color: theme.borderSubtle } // Import — offline-gated exactly like the sidebar's // "..." row. The importer modal carries its own offline // banner as the second layer; the row alone is not the // gate. Rectangle { id: plImportRow readonly property bool rowEnabled: !QbzSession.offline width: parent.width height: 40 radius: 5 color: (plImportRow.rowEnabled && plImportArea.containsMouse) ? theme.surfaceHover : "transparent" Row { anchors.fill: parent anchors.leftMargin: 12 anchors.rightMargin: 12 spacing: 8 QbzIcon { name: "import" width: 15 height: 15 anchors.verticalCenter: parent.verticalCenter tintName: plImportRow.rowEnabled ? "secondary" : "muted" } Text { width: parent.width - 23 height: parent.height text: QbzSession.tr("Import", QbzSession.trRev) color: plImportRow.rowEnabled ? theme.textSecondary : theme.textMuted font.pixelSize: 13 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } } MouseArea { id: plImportArea anchors.fill: parent enabled: plImportRow.rowEnabled hoverEnabled: plImportRow.rowEnabled cursorShape: plImportRow.rowEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: { QbzPlaylistImport.open() // Closing clears the shared search query // (plPopup.onClosed) so it does not silently // scope the expanded sidebar's list later. plPopup.close() } } } // Manage playlists — always available; the manager is a // local-store surface and the only place a hidden // playlist can be un-hidden. Rectangle { id: plManageRow width: parent.width height: 40 radius: 5 color: plManageArea.containsMouse ? theme.surfaceHover : "transparent" Row { anchors.fill: parent anchors.leftMargin: 12 anchors.rightMargin: 12 spacing: 8 QbzIcon { name: "library-big" width: 15 height: 15 anchors.verticalCenter: parent.verticalCenter tintName: "secondary" } Text { width: parent.width - 23 height: parent.height text: QbzSession.tr("Manage playlists", QbzSession.trRev) color: theme.textSecondary font.pixelSize: 13 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } } MouseArea { id: plManageArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { QbzPlaylistManager.navigate() plPopup.close() } } } } } } } } // --- Search (absolutely centered; LIVE — phase 15) --------------------- // The Slint HeaderBar search-scope: typing drives the cortinilla (220ms // debounce, >= 2 chars), arrows move the keyboard selection, Enter runs // the row / full search, Esc dismisses. The × clears + closes. function clearSearch() { searchInput.text = "" QbzSearch.cortinillaDismiss() } // nav.search / Ctrl+f (2026-08-03 hotkeys-port contract §4.3, divergence // K6): QbzHotkeys emits focusSearchRequested, the AppShell Connections // calls this. EXCEEDS Slint, whose focus_search only flipped // cortinilla_open — with an empty query the only visible effect was the // "↵ Enter" hint (the panel is gated on ≥2 chars; the "the field grabs // focus on open" comment at keybindings.rs:533-536 is stale). Here the // field lands focused and ready to type, which is what Ctrl+f means in // every other app. function focusSearch() { searchInput.forceActiveFocus() } Rectangle { id: searchBox x: (root.width - width) / 2 y: (root.height - height) / 2 // 80% of the prior search width; gives up 60px to the section nav // whenever that nav lives in the header (HeaderBar.slint:569). The // width animates and `x` re-centers with it. width: (root.width < 960 ? 179 : 256) - (QbzShell.navInSidebar ? 0 : 60) height: 32 Behavior on width { NumberAnimation { duration: 220; easing.type: Easing.InOutQuad } } radius: 6 border.width: 1 border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle // surface-elevated @ 0.5 under the dynamic background // (HeaderBar.slint:728) — it sits ON the already-translucent header, so // leaving it opaque left one solid slab in the middle of a bar the // field is otherwise showing through. color: root.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated QbzIcon { name: "search" width: 14 height: 14 x: 10 anchors.verticalCenter: parent.verticalCenter tintName: "muted" } TextInput { id: searchInput anchors.left: parent.left anchors.right: parent.right anchors.leftMargin: 30 anchors.rightMargin: 8 height: parent.height color: theme.textPrimary font.pixelSize: 13 verticalAlignment: Text.AlignVCenter horizontalAlignment: text === "" && !activeFocus ? Text.AlignHCenter : Text.AlignLeft clip: true // No QML debounce: the 220ms CORTINILLA_DEBOUNCE lives in Rust // now (search_qt::live). Debouncing the whole invokable here // meant the panel and its skeleton only appeared 220ms after the // LAST keystroke, so continuous typing showed nothing at all; // the reference opens on the FIRST keystroke >= 2 chars and // debounces only the LOAD. Rust's version guard is what discards // the superseded loads. onTextEdited: { if (text.trim().length < 2) { QbzSearch.cortinillaDismiss() } else { QbzSearch.searchLive(text) } } // The Enter rule (HeaderBar.slint on-enter): cortinilla open + // a keyboard selection -> activate the row; open + none -> full // search; closed -> plain submit (also Search > All). Keys.onPressed: function (event) { if (event.key === Qt.Key_Down) { if (QbzSearch.cortinillaOpen) { QbzSearch.cortinillaMoveSelection(1) event.accepted = true } } else if (event.key === Qt.Key_Up) { if (QbzSearch.cortinillaOpen) { QbzSearch.cortinillaMoveSelection(-1) event.accepted = true } } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { if (QbzSearch.cortinillaOpen) { if (QbzSearch.cortinillaSelectedIndex >= 0) { root.clearSearch() QbzSearch.cortinillaRowClicked(QbzSearch.cortinillaSelectedIndex) } else { root.clearSearch() QbzSearch.cortinillaSearchAll() } } else { // Capture BEFORE clearing (clearSearch wipes the input). var q = searchInput.text root.clearSearch() QbzSearch.searchSubmit(q) } event.accepted = true } else if (event.key === Qt.Key_Escape) { if (QbzSearch.cortinillaOpen) { QbzSearch.cortinillaDismiss() event.accepted = true } } } } // Placeholder — centered on the WHOLE box, exactly like // HeaderBar.slint:792 (`width: parent.width; horizontal-alignment: // center`). The magnifier is absolutely positioned at x:10 and takes // no layout space, so it must not shift the placeholder either: the // 30px left inset this used to carry pushed "Search" 15px right of // the field's centre, which is what reads as "not centred". Text { visible: searchInput.text === "" && !searchInput.activeFocus anchors.fill: parent text: QbzSession.tr("Search", QbzSession.trRev) color: theme.textMuted font.pixelSize: 13 verticalAlignment: Text.AlignVCenter horizontalAlignment: Text.AlignHCenter } // Right-edge affordances: the Enter hint while the cortinilla is // open (Slint: it lives in the box, opposite the magnifier), else // the × clear. Text { visible: QbzSearch.cortinillaOpen anchors.right: parent.right anchors.rightMargin: 10 height: parent.height text: "↵ " + QbzSession.tr("Enter", QbzSession.trRev) color: theme.textMuted font.pixelSize: 12 verticalAlignment: Text.AlignVCenter } Rectangle { visible: !QbzSearch.cortinillaOpen && searchInput.text !== "" anchors.right: parent.right anchors.rightMargin: 5 width: 22 height: 22 anchors.verticalCenter: parent.verticalCenter radius: 11 color: clearArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: "x" width: 12 height: 12 anchors.centerIn: parent tintName: clearArea.containsMouse ? "textPrimary" : "muted" } MouseArea { id: clearArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.clearSearch() } } } // --- Right controls: status badge + app menu -------------------------- Row { id: rightControls // Shifts left of the drawn window controls (3x34 + 2x2 = 106px) — but // ONLY when that cluster is actually drawn AND on the right. It used // to key off `systemTitleBar` alone, so macOS (which never draws the // cluster) reserved 110px of empty space at the right edge, and a // LEFT-placed cluster reserved it on the wrong side. The reference // gates the same padding on its `lin-chrome` predicate — the drawn // cluster's own condition, not the titlebar mode // (KioskShell.slint:245-248). x: root.width - width - theme.spacingMd + 2 - (root.chromeControls && !root.wcOnLeft ? 110 : 0) y: (root.height - height) / 2 height: 36 spacing: 4 // Offline status badge (OfflineStatusBadge) — ghost chrome like // NavTab: transparent, hover -> surface-hover, radius sm. Rectangle { visible: root.badgeState !== 0 height: 30 width: badgeRow.implicitWidth anchors.verticalCenter: parent.verticalCenter radius: theme.radiusSm color: badgeArea.containsMouse ? theme.surfaceHover : "transparent" readonly property string stateTintName: root.badgeState === 1 ? "warning" : root.badgeState === 2 ? "accent" : "muted" Row { id: badgeRow height: parent.height leftPadding: 9 rightPadding: 11 spacing: 6 QbzIcon { name: root.badgeState === 3 ? "user" : "cloud-off" width: 14 height: 14 anchors.verticalCenter: parent.verticalCenter tintName: parent.parent.stateTintName } Text { height: parent.height text: root.badgeState === 3 ? QbzSession.tr("Logged out", QbzSession.trRev) : root.badgeState === 2 ? QbzSession.tr("Offline (manual)", QbzSession.trRev) : QbzSession.tr("Offline (hard)", QbzSession.trRev) color: theme.textSecondary font.pixelSize: 11 verticalAlignment: Text.AlignVCenter } } MouseArea { id: badgeArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: badgeFlyout.open() } // Status flyout — the former banner texts + actions. Popup { id: badgeFlyout x: parent.width - 320 y: parent.height + 6 width: 320 padding: 14 closePolicy: Popup.CloseOnPressOutside background: Rectangle { color: theme.surfaceMain radius: theme.radiusSm border.width: 1 border.color: theme.borderMuted } contentItem: Column { spacing: 12 Row { spacing: 8 width: parent.width QbzIcon { name: root.badgeState === 3 ? "user" : "cloud-off" width: 15 height: 15 tintName: "muted" } Text { width: parent.width - 23 text: root.badgeState === 3 ? (QbzSession.connectivity === 2 ? QbzSession.tr("You're signed out. Sign-in needs a connection.", QbzSession.trRev) : QbzSession.connectivity === 1 ? QbzSession.tr("Connection available — sign back in to Qobuz.", QbzSession.trRev) : QbzSession.tr("You're signed out — sign back in to Qobuz.", QbzSession.trRev)) : root.badgeState === 2 ? QbzSession.tr("Offline mode is enabled. Disable it in Settings to use Qobuz.", QbzSession.trRev) : QbzSession.tr("No internet connection — your local library and downloads keep working.", QbzSession.trRev) color: theme.textPrimary font.pixelSize: 12 wrapMode: Text.WordWrap } } // Sign in — logged-out state only; disabled only when the // link is CONFIRMED down. Item { visible: root.badgeState === 3 width: parent.width height: visible ? 32 : 0 Rectangle { anchors.right: parent.right width: signInText.implicitWidth + 28 height: 32 radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle opacity: QbzSession.connectivity === 2 ? 0.4 : 1.0 color: signInArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated Text { id: signInText anchors.centerIn: parent text: QbzSession.tr("Sign in", QbzSession.trRev) color: theme.textSecondary font.pixelSize: 13 } MouseArea { id: signInArea anchors.fill: parent enabled: QbzSession.connectivity !== 2 hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { QbzSession.recoveryLogin() badgeFlyout.close() } } } } // POC-NOTE: the manual-offline quick toggle (state 2) // needs OfflineModeActions.set-offline — not wired; the // status text above still renders. } } } QbzIconButton { activeBackground: true name: "menu" anchors.verticalCenter: parent.verticalCenter onClicked: appMenu.open() } } // --- Drawn window controls (WindowControls.slint) --------------------- // Children are declared in the RIGHT order (minimize · maximize · close, // the Windows/KDE one; close gets the danger-red hover). Left placement // takes the corner the traffic lights use on macOS and flips to THEIR // order — close · maximize · minimize (WindowControls.slint:41-79). // // The flip is `layoutDirection`, not a reordered copy of the three // buttons: a Row laying out right-to-left puts the first child at the // right edge, so min·max·close reads close·max·min on screen. The // reference needs two mirrored `if` blocks because Slint has no such // property; duplicating them here would be duplicating the hover, the // icon swap and the close choreography three times over. // // `y` is deliberately untouched by the placement: the cluster stays // vertically centred on the header in BOTH positions (owner requirement, // and 1:1 with the reference — both of its blocks set the same // `y: (height - preferred-height) / 2`). Row { visible: root.chromeControls layoutDirection: root.wcOnLeft ? Qt.RightToLeft : Qt.LeftToRight x: root.wcOnLeft ? 8 : root.width - width - 8 y: (root.height - height) / 2 height: 26 spacing: 2 Rectangle { width: 34 height: 26 radius: theme.radiusSm color: wcMinArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: "minus" width: 14 height: 14 anchors.centerIn: parent tintName: "secondary" } MouseArea { id: wcMinArea anchors.fill: parent hoverEnabled: true onClicked: if (root.hostWindow) root.hostWindow.showMinimized() } } Rectangle { width: 34 height: 26 radius: theme.radiusSm color: wcMaxArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: root.hostWindow && root.hostWindow.visibility === Window.Maximized ? "minimize-2" : "maximize-2" width: 14 height: 14 anchors.centerIn: parent tintName: "secondary" } MouseArea { id: wcMaxArea anchors.fill: parent hoverEnabled: true onClicked: { if (root.hostWindow) { root.hostWindow.visibility = root.hostWindow.visibility === Window.Maximized ? Window.Windowed : Window.Maximized } } } } Rectangle { width: 34 height: 26 radius: theme.radiusSm color: wcCloseArea.containsMouse ? "#e81123" : "transparent" QbzIcon { name: "x" width: 14 height: 14 anchors.centerIn: parent // The hover fill is a FIXED #e81123 under every theme, so the // hovered glyph is literal white ("white"), not a theme // token — the one place in this file where the fixed bake is // the right answer. tintName: wcCloseArea.containsMouse ? "white" : "secondary" } MouseArea { id: wcCloseArea anchors.fill: parent hoverEnabled: true // Close-to-tray lives in ONE place — Main.qml's closeOrHide // (2026-08-03 miniplayer/tray contract, A-26 / §5.7) — which // hides to the tray while QbzTray.trayLive && QbzTray.closeToTray // and quits otherwise. A drawn button delivers no close EVENT // (Qt.quit() never raises `closing` either), hence the null. onClicked: if (root.hostWindow) root.hostWindow.closeOrHide(null) } } } // --- App menu (user block + Settings + Keyboard Shortcuts + // Documentation + What's New + About QBZ + Log Out + Close) ------------ // Still missing against the Slint menu, each needing a surface this port // does not have yet: Open Music Link (the LinkResolver is not ported) and // Report an Issue. (The older note here also listed Keyboard Shortcuts, // Documentation, What's New and About QBZ, all of which have landed.) Popup { id: appMenu x: root.width - 234 - theme.spacingMd y: theme.headerHeight - 4 width: 234 padding: 0 closePolicy: Popup.CloseOnPressOutside background: Rectangle { color: theme.surfaceMain radius: theme.radiusSm border.width: 1 border.color: theme.borderMuted } contentItem: Column { width: parent.width topPadding: 6 bottomPadding: 6 // Signed-in user — name and subscription tier. Column { width: parent.width leftPadding: 14 rightPadding: 14 topPadding: 6 bottomPadding: 10 spacing: 2 Text { text: QbzSession.sessionUserName === "" ? QbzSession.tr("Guest", QbzSession.trRev) : QbzSession.sessionUserName color: theme.textPrimary font.pixelSize: theme.fontBody font.weight: theme.weightSemibold } Text { text: QbzSession.sessionSubscription === "" ? QbzSession.tr("Not signed in", QbzSession.trRev) : QbzSession.sessionSubscription color: theme.textMuted font.pixelSize: 12 } } Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } Item { width: 1; height: 4 } // One menu row (MenuItem). component AppMenuItem: Rectangle { property string name: "" property string label: "" property bool checkedItem: false signal clicked() width: parent ? parent.width : 0 height: 34 color: miArea.containsMouse ? theme.surfaceHover : "transparent" Row { anchors.fill: parent anchors.leftMargin: 12 anchors.rightMargin: 18 spacing: 10 QbzIcon { name: parent.parent.name width: 15 height: 15 anchors.verticalCenter: parent.verticalCenter tintName: "secondary" } Text { id: miLabel height: parent.height text: parent.parent.label color: theme.textSecondary font.pixelSize: 13 verticalAlignment: Text.AlignVCenter } Item { visible: parent.parent.checkedItem width: visible ? parent.width - 15 - miLabel.implicitWidth - 14 - 2 * parent.spacing : 0 height: 1 } QbzIcon { visible: parent.parent.checkedItem name: "check" width: 14 height: 14 anchors.verticalCenter: parent.verticalCenter tintName: "accent" } } MouseArea { id: miArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: parent.clicked() } } // The app menu carries ACTIONS. Intelligent search, Ambient // background and Use system title bar were settings living here by // accident; all three already have their real rows in // Settings > Appearance, which is where Slint keeps them. AppMenuItem { name: "settings-2" label: QbzSession.tr("Settings", QbzSession.trRev) onClicked: { appMenu.close() QbzShell.navigateTo("settings") } } // Slint order (HeaderBar.slint:1175-1212): Settings, Open Music // Link, Keyboard Shortcuts, Documentation… The Open Music Link // row stays ABSENT (the LinkResolver is not ported, K3). AppMenuItem { name: "keyboard" label: QbzSession.tr("Keyboard Shortcuts", QbzSession.trRev) onClicked: { appMenu.close() QbzHotkeys.openCheatsheet() } } AppMenuItem { name: "book-open" label: QbzSession.tr("Documentation", QbzSession.trRev) onClicked: { appMenu.close() QbzShell.openExternalUrl("https://github.com/vicrodh/qbz/wiki") } } // Slint order continues (HeaderBar.slint:1213-1240): Report an // Issue -> What's New -> About QBZ -> divider -> Log Out. AppMenuItem { name: "bug" label: QbzSession.tr("Report an issue", QbzSession.trRev) onClicked: { appMenu.close() // The modal is mounted at the AppShell root (global modals // do not live inside the header). QML ids are file-scoped, // so this travels as a signal the host connects — the same // shape the window verbs use rather than a bridge // round-trip for state Rust has no use for. root.reportIssueRequested() } } AppMenuItem { name: "wand-sparkles" label: QbzSession.tr("What's New", QbzSession.trRev) onClicked: { appMenu.close() QbzAbout.whatsNewOpen() } } AppMenuItem { name: "info" label: QbzSession.tr("About QBZ", QbzSession.trRev) onClicked: { appMenu.close() QbzAbout.aboutOpen() } } // The divider Slint draws between About QBZ and Log Out // (HeaderBar.slint:1237-1240), in this file's own idiom (:1120). Item { width: 1; height: 4 } Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } Item { width: 1; height: 4 } AppMenuItem { name: "log-out" label: QbzSession.tr("Log Out", QbzSession.trRev) onClicked: { appMenu.close() QbzSession.logout() } } AppMenuItem { name: "x" label: QbzSession.tr("Close", QbzSession.trRev) // The same ONE close choreography as the drawn X above // (A-26, §5.7). appMenu.close() FIRST, like the Log Out row: // when close-to-tray only HIDES the window, a popup left open // comes back up with it on the next show. onClicked: { appMenu.close() if (root.hostWindow) root.hostWindow.closeOrHide(null) } } } } }