The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.
Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.
QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.
Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.
Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.
SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.
Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.
cc
1007 lines
43 KiB
QML
1007 lines
43 KiB
QML
// 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 (VISUAL
|
||
// replica — the cortinilla/live-search is out of scope; POC-NOTE).
|
||
// Right: the tri-state offline status badge with its flyout (recovery
|
||
// "Sign in" wired to QbzSession.recoveryLogin) and the app menu (user block
|
||
// + Documentation + Log Out + Close. Still missing vs Slint, each needing a
|
||
// surface the port does not have yet: Open Music Link, Keyboard Shortcuts,
|
||
// Report an Issue, What's New, About QBZ.
|
||
//
|
||
// POC-NOTE: the custom window-chrome parts (drag surface, drawn
|
||
// min/max/close WindowControls) are skipped — the POC keeps NATIVE window
|
||
// decorations.
|
||
|
||
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: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||
|
||
|
||
// The host ApplicationWindow (custom chrome); null in previews.
|
||
property var hostWindow: null
|
||
|
||
QbzTheme { id: theme }
|
||
|
||
// 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) — phase 12 titlebar toggle.
|
||
enabled: !QbzShell.systemTitleBar
|
||
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
|
||
|
||
// 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.
|
||
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
|
||
x: theme.spacingMd
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
// 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.
|
||
// GAP: Slint's footer (Import / Manage playlists) is omitted —
|
||
// this port has neither seam, and the sidebar "..." menu marks
|
||
// the same two as unavailable rather than faking them.
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- 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 = ""
|
||
QbzBridge.cortinillaDismiss()
|
||
}
|
||
|
||
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
|
||
color: 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
|
||
onTextEdited: {
|
||
if (text.trim().length < 2) {
|
||
liveDebounce.stop()
|
||
QbzBridge.cortinillaDismiss()
|
||
} else {
|
||
liveDebounce.restart()
|
||
}
|
||
}
|
||
|
||
// 220ms live debounce (CORTINILLA_DEBOUNCE — one load per pause,
|
||
// not one per keystroke).
|
||
Timer {
|
||
id: liveDebounce
|
||
interval: 220
|
||
repeat: false
|
||
onTriggered: QbzBridge.searchLive(searchInput.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 (QbzBridge.cortinillaOpen) {
|
||
QbzBridge.cortinillaMoveSelection(1)
|
||
event.accepted = true
|
||
}
|
||
} else if (event.key === Qt.Key_Up) {
|
||
if (QbzBridge.cortinillaOpen) {
|
||
QbzBridge.cortinillaMoveSelection(-1)
|
||
event.accepted = true
|
||
}
|
||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||
if (QbzBridge.cortinillaOpen) {
|
||
if (QbzBridge.selectedIndex >= 0) {
|
||
root.clearSearch()
|
||
QbzBridge.cortinillaRowClicked(QbzBridge.selectedIndex)
|
||
} else {
|
||
root.clearSearch()
|
||
QbzBridge.cortinillaSearchAll()
|
||
}
|
||
} else {
|
||
// Capture BEFORE clearing (clearSearch wipes the input).
|
||
var q = searchInput.text
|
||
root.clearSearch()
|
||
QbzBridge.searchSubmit(q)
|
||
}
|
||
event.accepted = true
|
||
} else if (event.key === Qt.Key_Escape) {
|
||
if (QbzBridge.cortinillaOpen) {
|
||
QbzBridge.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: QbzBridge.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: !QbzBridge.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).
|
||
x: root.width - width - theme.spacingMd + 2 - (QbzShell.systemTitleBar ? 0 : 110)
|
||
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, right placement:
|
||
// minimize · maximize · close; close gets the danger-red hover) ------
|
||
Row {
|
||
visible: !QbzShell.systemTitleBar
|
||
x: 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
|
||
// POC-NOTE: the Slint app hides-to-tray on close; the POC
|
||
// has no tray — close quits.
|
||
onClicked: Qt.quit()
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- App menu (user block + Settings + Log Out + Close) ---------------
|
||
// POC-NOTE: the Slint menu also carries Open Music Link / Keyboard
|
||
// Shortcuts / Documentation / Report an Issue / What's New / About QBZ
|
||
// — omitted here (no views/dialogs to open yet).
|
||
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")
|
||
}
|
||
}
|
||
AppMenuItem {
|
||
name: "book-open"
|
||
label: QbzSession.tr("Documentation", QbzSession.trRev)
|
||
onClicked: {
|
||
appMenu.close()
|
||
QbzShell.openExternalUrl("https://github.com/vicrodh/qbz/wiki")
|
||
}
|
||
}
|
||
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)
|
||
// POC-NOTE: the Slint app hides to tray / follows the
|
||
// platform close behavior; the POC just quits.
|
||
onClicked: Qt.quit()
|
||
}
|
||
}
|
||
}
|
||
}
|