Recover missing offline library entries, recognize cached-only libraries, and refresh availability after cache changes. Unify per-tab filters, compact the toolbar, and add reversible sort criteria with complete available purchase metadata. cx
734 lines
33 KiB
QML
734 lines
33 KiB
QML
// Section-nav dropdown flyout — ONE component, mounted by BOTH nav hosts
|
|
// (HeaderBar.qml and Sidebar.qml). The QML port of Slint's
|
|
// crates/qbz-ui/ui/shell/HeaderMenuOverlay.slint + NavMenuItem.slint, plus
|
|
// the entry catalog the Slint tree duplicates three times (Sidebar.slint:724,
|
|
// HeaderBar.slint:858 full tabs, HeaderBar.slint:974 compact buttons).
|
|
//
|
|
// WHY ONE FILE OWNS THE CATALOG TOO: Slint keeps three copies of the same
|
|
// [NavMenuEntry] literals because its `if` mount sites cannot share an array
|
|
// expression. QML can, so `sections` lives here and both hosts read it —
|
|
// there is exactly one place to add a section or an entry.
|
|
//
|
|
// WHY A Popup: Slint mounts HeaderMenuOverlay as the LAST child of the
|
|
// AppShell root because a menu hosted inside the 42px header would be clipped
|
|
// by the content area (and a PopupWindow would grab the pointer, killing
|
|
// hover-switching between tabs). QtQuick.Controls' Popup reparents its
|
|
// content to the window overlay, which gives the same "above everything, not
|
|
// clipped by my host" placement WITHOUT touching AppShell.qml — and a
|
|
// non-modal Popup does not grab hover, so hover-switching works. The
|
|
// closed-sidebar playlists flyout in HeaderBar.qml already uses this pattern.
|
|
//
|
|
// Open/close behaviour is 1:1 with the Slint overlay:
|
|
// - hovering OR clicking a trigger opens that section's menu;
|
|
// - hovering a different trigger overwrites the open menu (instant switch,
|
|
// single-open is automatic — there is one panel);
|
|
// - the menu closes after 1s idle, and the countdown is PAUSED while the
|
|
// pointer rests on the trigger or anywhere on the panel (panel body OR a
|
|
// row — hence the two-flag OR, HeaderMenuOverlay.slint:41);
|
|
// - a press outside, or Escape, closes it.
|
|
//
|
|
// GAP vs Slint: no drop shadow — the 1px border-muted outline carries the
|
|
// separation instead.
|
|
//
|
|
// SUPERSEDED (2026-07-29), the REASON this gap used to give: "Qt's shadow
|
|
// effects (Qt5Compat / QtQuick.Effects MultiEffect) render NOTHING on the
|
|
// software path this port runs on". Effects need shaders. This port runs on
|
|
// the GPU (OpenGL RHI, measured 2026-07-29); the earlier "renders nothing"
|
|
// note came from an offscreen session, which forces the software renderer by
|
|
// definition. Where a software path is genuinely possible, detect it with
|
|
// `GraphicsInfo.api` rather than assuming it (theme/RoundedImage.qml does).
|
|
// The gap itself STAYS for now: adding the shadow is a visual change that
|
|
// needs its own parity pass against Slint's `drop-shadow-blur`, not a perf
|
|
// fix — do not add one in a domain commit.
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import com.blitzfc.qbz
|
|
import "../theme"
|
|
|
|
Item {
|
|
id: nav
|
|
// Zero-size, non-layout host: it exists only to give the Popup a
|
|
// coordinate system (the host root's) and to carry the shared catalog.
|
|
// Mount it as a DIRECT child of the host root, never inside a Row/Column,
|
|
// so its origin stays the host root's origin.
|
|
width: 0
|
|
height: 0
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Catalog
|
|
// ---------------------------------------------------------------------
|
|
|
|
// Slint gates the Recommendations entry on SettingsState.show-recommendations
|
|
// (discover_prefs `show_recommendations`, default ON) — the port publishes
|
|
// it in QbzBridge.settingsJson.
|
|
readonly property var settingsDoc: {
|
|
try {
|
|
return JSON.parse(QbzBridge.settingsJson)
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
readonly property bool showRecommendations:
|
|
nav.settingsDoc.showRecommendations !== false
|
|
readonly property var localTabOrder: {
|
|
var stored = nav.settingsDoc.localTabOrder
|
|
return Array.isArray(stored) && stored.length > 0
|
|
? stored : ["genres", "albums", "artists", "folders", "tracks"]
|
|
}
|
|
|
|
// MyQBZ branding — `label` (user-editable in Settings > Appearance,
|
|
// coerced back to "My QBZ" on the Rust side when cleared), `iconPath` +
|
|
// `hasCustomIcon` for a user-supplied section icon. Published on
|
|
// QbzMyQbz.brandingJson, seeded at construction (not at boot()) precisely
|
|
// because this catalog binds it on the FIRST frame.
|
|
//
|
|
// The parse is GUARDED (the parseDoc idiom, views/PlaylistView.qml:53-60):
|
|
// a raw in-binding JSON.parse throws on the pre-publish frame and takes
|
|
// the whole nav catalog down with it — both hosts included, since both
|
|
// read `sections` from here. The try also covers QbzMyQbz not being
|
|
// registered at all (an unknown type name is a plain JS global lookup, so
|
|
// it raises a catchable ReferenceError rather than a load error).
|
|
readonly property var branding: parseBranding()
|
|
function parseBranding() {
|
|
try {
|
|
return JSON.parse(QbzMyQbz.brandingJson)
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
|
|
// Rust never publishes an empty label — it coerces a cleared one back to
|
|
// the English literal "My QBZ" — so the localised fallback below is
|
|
// defensive only, and the SHIPPED default is that English literal. That is
|
|
// a pre-existing i18n hole shared with the Slint app
|
|
// (MyQbzBrandingState.label's default is the same literal); do not "fix"
|
|
// it here by second-guessing the Rust value.
|
|
readonly property string brandingLabel: {
|
|
var l = nav.branding.label || ""
|
|
return l !== "" ? l : nav.trs("My QBZ")
|
|
}
|
|
readonly property string brandingIconPath:
|
|
nav.branding.hasCustomIcon === true ? (nav.branding.iconPath || "") : ""
|
|
|
|
// The four top-level sections, in Slint order. `rev` is threaded through
|
|
// buildSections purely so the binding re-runs on a language switch
|
|
// (QbzSession.trRev is the translation revision counter every other
|
|
// tr() call site binds to).
|
|
readonly property var sections:
|
|
buildSections(QbzSession.trRev, showRecommendations, localTabOrder)
|
|
|
|
function trs(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
|
|
|
// Entry shape: { label, icon (baked glyph name, "" = no glyph),
|
|
// view (shell route), tab (view-internal tab id),
|
|
// enabled }
|
|
// Section shape: { id, icon, label, qobuz (hidden while offline),
|
|
// enabled, entries,
|
|
// iconPath — OPTIONAL: absent or "" means "use the baked
|
|
// `icon`"; a non-empty path is a user-supplied image
|
|
// rendered raw by shell/NavSectionGlyph.qml. Only the
|
|
// MyQBZ section can carry one. }
|
|
function buildSections(rev, reco, localOrder) {
|
|
// Discover — glyph-less rows (Slint has-glyph: false).
|
|
var discover = [
|
|
{ "label": nav.trs("Home"), "icon": "", "view": "home", "tab": "home", "enabled": true },
|
|
{ "label": nav.trs("For You"), "icon": "", "view": "home", "tab": "forYou", "enabled": true },
|
|
{ "label": nav.trs("Editor's Picks"), "icon": "", "view": "home", "tab": "editorPicks", "enabled": true }
|
|
]
|
|
if (reco) {
|
|
discover.push({ "label": nav.trs("Recommendations"), "icon": "", "view": "home", "tab": "recommendations", "enabled": true })
|
|
}
|
|
// Local Library's fixed surfaces have ONE authoritative user order.
|
|
// Build both flyout hosts from it, then append the ephemeral Open
|
|
// submenu: Open is an action/session, not a reorderable landing tab.
|
|
var localById = {
|
|
"albums": { "label": nav.trs("Albums"), "icon": "disc", "view": "local", "tab": "albums", "enabled": true },
|
|
"artists": { "label": nav.trs("Artists"), "icon": "user", "view": "local", "tab": "artists", "enabled": true },
|
|
"genres": { "label": nav.trs("Library Explorer"), "icon": "music-note-slider", "view": "local", "tab": "genres", "enabled": true },
|
|
"folders": { "label": QbzSession.offline ? nav.trs("Folder view") : nav.trs("Folders"),
|
|
"icon": "folder", "view": "local", "tab": "folders", "enabled": true },
|
|
"tracks": { "label": nav.trs("Tracks"), "icon": "music", "view": "local", "tab": "tracks", "enabled": true }
|
|
}
|
|
var localEntries = []
|
|
var seenLocal = ({})
|
|
for (var li = 0; li < localOrder.length; li++) {
|
|
var localId = localOrder[li]
|
|
if (localById[localId] && seenLocal[localId] !== true) {
|
|
localEntries.push(localById[localId])
|
|
seenLocal[localId] = true
|
|
}
|
|
}
|
|
// Defensive completion for the construction-time/pre-migration frame.
|
|
var localFallback = ["genres", "albums", "artists", "folders", "tracks"]
|
|
for (var lf = 0; lf < localFallback.length; lf++) {
|
|
var fallbackId = localFallback[lf]
|
|
if (seenLocal[fallbackId] !== true)
|
|
localEntries.push(localById[fallbackId])
|
|
}
|
|
localEntries.push({
|
|
"label": QbzLocal.localEphemeralActive
|
|
? QbzLocal.localEphemeralLabel
|
|
: (QbzSession.offline ? nav.trs("Open media") : nav.trs("Open")),
|
|
"icon": "folder-open", "view": "", "tab": "", "enabled": true,
|
|
"hasSubmenu": true, "submenu": nav.openMediaEntries()
|
|
})
|
|
return [
|
|
{
|
|
"id": "discover",
|
|
"icon": "compass",
|
|
"label": nav.trs("Discover"),
|
|
"qobuz": true,
|
|
"enabled": true,
|
|
"entries": discover
|
|
},
|
|
{
|
|
"id": "library",
|
|
"icon": "music-library-2",
|
|
"label": nav.trs("Library"),
|
|
"qobuz": true,
|
|
"enabled": true,
|
|
"entries": [
|
|
{ "label": nav.trs("All"), "icon": "layout-grid", "view": "library", "tab": "all", "enabled": true },
|
|
{ "label": nav.trs("Tracks"), "icon": "music", "view": "library", "tab": "tracks", "enabled": true },
|
|
{ "label": nav.trs("Releases"), "icon": "disc", "view": "library", "tab": "albums", "enabled": true },
|
|
{ "label": nav.trs("Artists"), "icon": "user", "view": "library", "tab": "artists", "enabled": true },
|
|
{ "label": nav.trs("Playlists"), "icon": "list-music", "view": "library", "tab": "playlists", "enabled": true },
|
|
{ "label": nav.trs("Labels"), "icon": "disc-3", "view": "library", "tab": "labels", "enabled": true }
|
|
]
|
|
},
|
|
{
|
|
"id": "local",
|
|
"icon": "hard-drive",
|
|
"label": nav.trs("Local Library"),
|
|
"qobuz": false,
|
|
"enabled": true,
|
|
"entries": localEntries
|
|
},
|
|
{
|
|
"id": "myqbz",
|
|
"icon": "qbz-symbolic",
|
|
// Slint's HEADER hosts label this tab @tr("My QBZ")
|
|
// (HeaderBar.slint:938, :1048) while only the sidebar and the
|
|
// kiosk rail read MyQbzBrandingState.label (Sidebar.slint:787,
|
|
// NavRail.slint:145). ONE catalog cannot express that split,
|
|
// and a user who renames the section expects the rename
|
|
// everywhere, so both hosts get the branding label.
|
|
"label": nav.brandingLabel,
|
|
// Custom section icon, rendered raw (its own colours, no theme
|
|
// tint) — Slint's SidebarNavRow `raw-icon` arm.
|
|
"iconPath": nav.brandingIconPath,
|
|
"qobuz": false,
|
|
"enabled": true,
|
|
"entries": [
|
|
// ORDER is the header's: Collections then Mixtapes
|
|
// (HeaderBar.slint:941-942). Slint's sidebar contradicts
|
|
// itself with Mixtapes first (Sidebar.slint:790-791), and
|
|
// one catalog serves both hosts.
|
|
// ICONS are the header's pair too: cassette-tape +
|
|
// library-big are baked in all eight tint dirs, while the
|
|
// sidebar's cassette + collection are not — preferring
|
|
// them would manufacture two asset imports for a visually
|
|
// equivalent glyph.
|
|
{ "label": nav.trs("Collections"), "icon": "library-big", "view": "collections", "tab": "", "enabled": true },
|
|
{ "label": nav.trs("Mixtapes"), "icon": "cassette-tape", "view": "mixtapes", "tab": "", "enabled": true }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
/// Second-level catalog for Local Library > Open. When a medium already
|
|
/// exists its own row returns to it and Close is appended; all three open
|
|
/// verbs remain available so replacing one medium never needs an extra
|
|
/// navigation round trip.
|
|
function openMediaEntries() {
|
|
var entries = []
|
|
if (QbzLocal.localEphemeralActive) {
|
|
entries.push({ "label": QbzLocal.localEphemeralLabel,
|
|
"icon": "folder-open", "view": "local",
|
|
"tab": "ephemeral", "enabled": true })
|
|
}
|
|
entries.push(
|
|
{ "label": nav.trs("Open folder…"), "icon": "folder-open",
|
|
"view": "", "tab": "", "action": "openFolder", "enabled": true })
|
|
// W16: qbz-disc has no Windows CDDA backend -- `mod unsupported`
|
|
// compiles and every call returns NoDrive. Offering the entry would
|
|
// only ever produce "no drive found" on a machine that has one. SACD
|
|
// stays: that opens an image file, which needs no drive.
|
|
if (!QbzShell.isWindows) {
|
|
entries.push(
|
|
{ "label": nav.trs("Open audio CD"), "icon": "disc",
|
|
"view": "", "tab": "", "action": "openCd", "enabled": true })
|
|
}
|
|
entries.push(
|
|
{ "label": nav.trs("Open SACD image…"), "icon": "disc-3",
|
|
"view": "", "tab": "", "action": "openSacd", "enabled": true })
|
|
if (QbzLocal.localEphemeralActive) {
|
|
entries.push({ "label": nav.trs("Close"), "icon": "x",
|
|
"view": "", "tab": "", "action": "closeMedia",
|
|
"enabled": true })
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// Which section owns the highlight, derived from the LIVE view (both hosts
|
|
// used to carry this identical binding). Slint highlights off
|
|
// HeaderMenuState.open-index; the hosts OR that in via `openId`.
|
|
//
|
|
// Each alternative spells `QbzShell.currentView === x` out in full: the
|
|
// shorthand `a === x || y || z` is ALWAYS truthy (`"y"` is a non-empty
|
|
// string), which would light the section up on every route in the app.
|
|
// "discobuilder" is deliberately NOT in the MyQBZ arm — Slint's own active
|
|
// predicate omits the builder (NavRail.slint:146-148).
|
|
readonly property string activeSection:
|
|
QbzShell.currentView === "home" ? "discover"
|
|
: QbzShell.currentView === "library" ? "library"
|
|
: (QbzShell.currentView === "local" || QbzShell.currentView === "localalbum") ? "local"
|
|
: (QbzShell.currentView === "mixtapes"
|
|
|| QbzShell.currentView === "collections"
|
|
|| QbzShell.currentView === "mixtapedetail") ? "myqbz"
|
|
: ""
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Open / close (HeaderMenuState)
|
|
// ---------------------------------------------------------------------
|
|
|
|
// Section id whose menu is open ("" = none) — the port's open-index.
|
|
// Keyed on `visible` (flips the instant open()/close() runs), not on
|
|
// `opened` (which waits out an enter transition).
|
|
readonly property string openId: panel.visible ? panel.sectionId : ""
|
|
// True while the pointer rests on the TRIGGER. The trigger owns this flag
|
|
// (HeaderMenuOverlay.slint:51): only the trigger that owns the open menu
|
|
// clears it, which avoids a leave/enter race when sliding between triggers.
|
|
property bool triggerHovered: false
|
|
|
|
// Header dropdowns hang at a fixed window y (Slint: 45px = the 42px header
|
|
// plus 3), NOT under each tab — the host mounts this at its own origin, so
|
|
// in HeaderBar (top of the window) the two coincide.
|
|
property real dropdownY: 45
|
|
|
|
// Header form: menu under the trigger, left edges aligned.
|
|
// `withTitle` = the section-name header row + hairline, which Slint shows
|
|
// for the compact icon buttons (the icon alone does not name the section)
|
|
// and omits for the full text tabs.
|
|
function openUnder(trigger, section, withTitle) {
|
|
if (!section || section.enabled === false) return
|
|
panel.show(section, withTitle === true, trigger.mapToItem(nav, 0, 0).x, nav.dropdownY)
|
|
}
|
|
|
|
// Sidebar form: menu to the RIGHT of the row (+6px), aligned to its top.
|
|
// The expanded row already names the section, while the compact icon does
|
|
// not; callers pass that visible-text fact explicitly.
|
|
function openBeside(trigger, section, withTitle) {
|
|
if (!section || section.enabled === false) return
|
|
var p = trigger.mapToItem(nav, trigger.width + 6, 0)
|
|
panel.show(section, withTitle === true, p.x, p.y)
|
|
}
|
|
|
|
function closeMenu() { panel.close() }
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Entry activation
|
|
// ---------------------------------------------------------------------
|
|
|
|
// Entries with a tab go through the bridge seam (navigateToTab), which
|
|
// both navigates and hands the tab to ContentRouter's Binding — the
|
|
// Slint behaviour (on_header_menu_navigate selects the landing tab
|
|
// per route, crates/qbz/src/main.rs:18026-18046). Entries without one
|
|
// (Collections, Mixtapes) are a plain section navigation.
|
|
function activate(entry) {
|
|
if (entry && entry.submenu) return
|
|
panel.close()
|
|
if (!entry || entry.enabled === false) return
|
|
if (entry.action === "openFolder") {
|
|
QbzLocal.ephemeralOpen()
|
|
return
|
|
}
|
|
if (entry.action === "openCd") {
|
|
QbzLocal.ephemeralOpenCd()
|
|
return
|
|
}
|
|
if (entry.action === "openSacd") {
|
|
QbzLocal.ephemeralOpenSacd()
|
|
return
|
|
}
|
|
if (entry.action === "closeMedia") {
|
|
QbzLocal.ephemeralClear()
|
|
return
|
|
}
|
|
if (entry.view === "") return
|
|
if (entry.tab && entry.tab !== "")
|
|
QbzShell.navigateToTab(entry.view, entry.tab)
|
|
else
|
|
QbzShell.navigateTo(entry.view)
|
|
}
|
|
|
|
// OPT-IN (Settings > Appearance, OFF by default): a click on a top-level
|
|
// section row also navigates, landing on that section's FIRST entry —
|
|
// Discover on Home, Library on All. Shipped behaviour is that a click only
|
|
// opens the flyout and the user picks from it, which is what `false` keeps.
|
|
//
|
|
// It lives HERE and not in the two hosts because the catalog lives here:
|
|
// "the first tab" is `entries[0]`, and reusing `activate` means the MyQBZ
|
|
// section — whose entries are plain views with no tab — needs no special
|
|
// case. Sidebar.qml and HeaderBar.qml each call this from their row's
|
|
// `onClicked`, next to the open they already do.
|
|
//
|
|
// GUARDED parse, the same precedent as `branding` above: a bare
|
|
// JSON.parse in a binding throws on the pre-publish frame and would take
|
|
// the whole nav catalog — and therefore both hosts — down with it.
|
|
readonly property bool clickNavigates: {
|
|
try {
|
|
return JSON.parse(QbzBridge.settingsJson).navClickFirstTab === true
|
|
} catch (e) {
|
|
return false
|
|
}
|
|
}
|
|
/// Call from a section row's click. A no-op unless the user opted in.
|
|
function sectionClicked(section) {
|
|
if (!nav.clickNavigates || !section || section.enabled === false)
|
|
return
|
|
var entries = section.entries || []
|
|
if (entries.length > 0)
|
|
nav.activate(entries[0])
|
|
}
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
// Idle-close (project rule: 1s), paused while the pointer rests on the
|
|
// menu or on its trigger. Lives HERE and not inside the Popup: the Popup
|
|
// carries an explicit contentItem, so a non-visual default-property child
|
|
// would be handed to that content item instead of the popup.
|
|
Timer {
|
|
interval: 1000
|
|
repeat: false
|
|
running: panel.visible && !panel.menuHovered
|
|
onTriggered: panel.close()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// The panel (HeaderMenuOverlay's Rectangle + NavMenuItem rows)
|
|
// ---------------------------------------------------------------------
|
|
|
|
// One menu row — NavMenuItem.slint: 32px, hover surface-hover, 14px glyph
|
|
// (optional), 13px text-secondary label, padding 14 left / 22 right.
|
|
component NavMenuRow: Rectangle {
|
|
id: row
|
|
property var entry: null
|
|
property int rowIndex: -1
|
|
readonly property bool rowEnabled: entry && entry.enabled !== false
|
|
// QVariantList conversion makes runtime array introspection unreliable.
|
|
// The catalog explicitly marks the sole cascading entry instead.
|
|
readonly property bool hasSubmenu: !!(entry && entry.hasSubmenu === true)
|
|
|
|
width: parent ? parent.width : 0
|
|
height: 32
|
|
opacity: rowEnabled ? 1.0 : 0.45
|
|
color: (rowEnabled && rowArea.containsMouse) ? theme.surfaceHover : "transparent"
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 14
|
|
anchors.rightMargin: 22
|
|
spacing: 9
|
|
|
|
QbzIcon {
|
|
visible: row.entry && row.entry.icon !== ""
|
|
width: visible ? 14 : 0
|
|
height: 14
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: row.entry ? row.entry.icon : ""
|
|
tintName: "secondary"
|
|
}
|
|
Text {
|
|
height: parent.height
|
|
width: parent.width
|
|
- (row.entry && row.entry.icon !== "" ? 23 : 0)
|
|
- (row.hasSubmenu ? 19 : 0)
|
|
text: row.entry ? row.entry.label : ""
|
|
color: theme.textSecondary
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
QbzIcon {
|
|
visible: row.hasSubmenu
|
|
width: visible ? 11 : 0
|
|
height: 11
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: "chevron-right"
|
|
tintName: "muted"
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: rowArea
|
|
anchors.fill: parent
|
|
enabled: row.rowEnabled
|
|
hoverEnabled: row.rowEnabled
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (row.hasSubmenu)
|
|
panel.openCascade(row, row.entry)
|
|
else
|
|
nav.activate(row.entry)
|
|
}
|
|
// Per-row hover keeps the idle timer paused while the cursor RESTS
|
|
// on a row (the panel-body tracker below never sees it — the row
|
|
// is on top of it).
|
|
onContainsMouseChanged: {
|
|
if (containsMouse) {
|
|
panel.hoveredRow = row.rowIndex
|
|
if (row.hasSubmenu)
|
|
panel.openCascade(row, row.entry)
|
|
else
|
|
panel.scheduleCascadeClose()
|
|
} else {
|
|
if (panel.hoveredRow === row.rowIndex) panel.hoveredRow = -1
|
|
if (panel.cascadeAnchorRow === row.rowIndex)
|
|
panel.scheduleCascadeClose()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Popup {
|
|
id: panel
|
|
width: 188
|
|
padding: 0
|
|
topPadding: 6
|
|
bottomPadding: 6
|
|
// Deterministic height (Slint: panel-box.preferred-height) — 32px per
|
|
// row, plus the optional 26px title band and its 1px hairline.
|
|
height: 12 + (panel.title === "" ? 0 : 27) + panel.entries.length * 32
|
|
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
|
|
|
|
property string sectionId: ""
|
|
property string title: ""
|
|
property var entries: []
|
|
// Which row the pointer is on (-1 = none); rows do not overlap.
|
|
property int hoveredRow: -1
|
|
// True while the pointer rests on the panel BODY (padding/gaps).
|
|
property bool bodyHovered: false
|
|
// The cascade remains live while the pointer crosses the panel edge.
|
|
// 280ms is long enough for a diagonal move but short enough that an
|
|
// abandoned submenu does not become perennial.
|
|
property int cascadeAnchorRow: -1
|
|
property bool cascadeGrace: false
|
|
// Pointer anywhere on the menu, or on the trigger that owns it — the
|
|
// idle countdown is paused while this holds.
|
|
readonly property bool menuHovered:
|
|
bodyHovered || hoveredRow >= 0 || nav.triggerHovered
|
|
|| cascade.menuHovered || cascadeGrace
|
|
|
|
function openCascade(row, entry) {
|
|
cascadeClose.stop()
|
|
panel.cascadeGrace = false
|
|
panel.cascadeAnchorRow = row.rowIndex
|
|
cascade.entries = entry.submenu || []
|
|
cascade.x = panel.width - 2
|
|
cascade.y = row.mapToItem(panel.contentItem, 0, 0).y
|
|
if (!cascade.visible) cascade.open()
|
|
}
|
|
|
|
function scheduleCascadeClose() {
|
|
if (!cascade.visible) return
|
|
panel.cascadeGrace = true
|
|
cascadeClose.restart()
|
|
}
|
|
|
|
function show(section, withTitle, px, py) {
|
|
// Switching sections: drop stale hover state (the new rows have
|
|
// not reported yet), exactly like the Slint `changed open-index`.
|
|
if (panel.sectionId !== section.id) panel.hoveredRow = -1
|
|
if (panel.sectionId !== section.id) cascade.close()
|
|
panel.sectionId = section.id
|
|
panel.title = withTitle ? section.label : ""
|
|
panel.entries = section.entries
|
|
panel.x = px
|
|
panel.y = py
|
|
if (!panel.visible) panel.open()
|
|
}
|
|
|
|
onClosed: {
|
|
panel.sectionId = ""
|
|
panel.hoveredRow = -1
|
|
panel.bodyHovered = false
|
|
panel.cascadeAnchorRow = -1
|
|
panel.cascadeGrace = false
|
|
cascade.close()
|
|
nav.triggerHovered = false
|
|
}
|
|
|
|
background: Rectangle {
|
|
color: theme.surfaceMain
|
|
radius: theme.radiusSm
|
|
border.width: 1
|
|
border.color: theme.borderMuted
|
|
|
|
// Panel-body hover tracker. It lives in the BACKGROUND, i.e. below
|
|
// the content item, so it can never swallow a row's press (a fill
|
|
// MouseArea declared over the rows would — a press is not a
|
|
// composed event and propagateComposedEvents does not rescue it).
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onContainsMouseChanged: panel.bodyHovered = containsMouse
|
|
// Swallow clicks on the panel chrome so they do not fall
|
|
// through to whatever sits under the flyout.
|
|
onClicked: {}
|
|
}
|
|
}
|
|
|
|
contentItem: Column {
|
|
spacing: 0
|
|
|
|
// Optional section-name header + hairline (Slint: shown for the
|
|
// compact header buttons and every sidebar flyout).
|
|
Text {
|
|
visible: panel.title !== ""
|
|
width: parent.width
|
|
height: visible ? 26 : 0
|
|
leftPadding: 14
|
|
rightPadding: 14
|
|
text: panel.title
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
font.weight: theme.weightSemibold
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
Rectangle {
|
|
visible: panel.title !== ""
|
|
width: parent.width
|
|
height: visible ? 1 : 0
|
|
color: theme.borderSubtle
|
|
}
|
|
|
|
Repeater {
|
|
model: panel.entries
|
|
delegate: NavMenuRow {
|
|
required property var modelData
|
|
required property int index
|
|
entry: modelData
|
|
rowIndex: index
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: cascadeClose
|
|
interval: 280
|
|
repeat: false
|
|
onTriggered: {
|
|
panel.cascadeGrace = false
|
|
if (panel.cascadeAnchorRow !== panel.hoveredRow && !cascade.menuHovered)
|
|
cascade.close()
|
|
}
|
|
}
|
|
|
|
// Child Popup, not a sibling: presses inside it count as part of the
|
|
// menu family for CloseOnPressOutside, and closing the parent tears it
|
|
// down automatically. The panels overlap by 2px so there is no dead
|
|
// seam; the timer above tolerates diagonal pointer travel.
|
|
Popup {
|
|
id: cascade
|
|
width: 224
|
|
padding: 0
|
|
topPadding: 6
|
|
bottomPadding: 6
|
|
height: 12 + cascade.entries.length * 32
|
|
closePolicy: Popup.NoAutoClose
|
|
|
|
property var entries: []
|
|
property int hoveredRow: -1
|
|
property bool bodyHovered: false
|
|
readonly property bool menuHovered: bodyHovered || hoveredRow >= 0
|
|
|
|
onClosed: {
|
|
cascade.entries = []
|
|
cascade.hoveredRow = -1
|
|
cascade.bodyHovered = false
|
|
panel.cascadeAnchorRow = -1
|
|
}
|
|
|
|
background: Rectangle {
|
|
color: theme.surfaceMain
|
|
radius: theme.radiusSm
|
|
border.width: 1
|
|
border.color: theme.borderMuted
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onContainsMouseChanged: {
|
|
cascade.bodyHovered = containsMouse
|
|
if (containsMouse) {
|
|
cascadeClose.stop()
|
|
panel.cascadeGrace = false
|
|
} else {
|
|
panel.scheduleCascadeClose()
|
|
}
|
|
}
|
|
onClicked: {}
|
|
}
|
|
}
|
|
|
|
contentItem: Column {
|
|
Repeater {
|
|
model: cascade.entries
|
|
delegate: Rectangle {
|
|
id: cascadeRow
|
|
required property var modelData
|
|
required property int index
|
|
width: parent ? parent.width : 0
|
|
height: 32
|
|
color: cascadeArea.containsMouse ? theme.surfaceHover : "transparent"
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 14
|
|
anchors.rightMargin: 14
|
|
spacing: 9
|
|
QbzIcon {
|
|
width: 14
|
|
height: 14
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
name: cascadeRow.modelData.icon || ""
|
|
tintName: "secondary"
|
|
}
|
|
Text {
|
|
width: parent.width - 23
|
|
height: parent.height
|
|
text: cascadeRow.modelData.label || ""
|
|
color: theme.textSecondary
|
|
font.pixelSize: 13
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: cascadeArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: nav.activate(cascadeRow.modelData)
|
|
onContainsMouseChanged: {
|
|
if (containsMouse) {
|
|
cascade.hoveredRow = cascadeRow.index
|
|
cascadeClose.stop()
|
|
panel.cascadeGrace = false
|
|
} else {
|
|
if (cascade.hoveredRow === cascadeRow.index)
|
|
cascade.hoveredRow = -1
|
|
panel.scheduleCascadeClose()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|