Files
qbz/crates/qbz-qt/qml/shell/NavFlyout.qml
T
vicrodh 17e08e93de fix(library): restore offline visibility and unify filters and sorting
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
2026-09-09 17:08:33 -06:00

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()
}
}
}
}
}
}
}
}
}