Files
qbz/crates/qbz-qt/qml/shell/NavFlyout.qml
T
vicrodh 5ba49a7390 fix(qt): hide the Windows rows nothing can honour
Three settings and menu entries offered Windows something no code behind them
could do.

DSD playback was opened to WASAPI in fc0d35e29, written before the phase B
spike. The spike then measured this DAC refusing 176400, the DSD64 DoP
carrier, so DoP over exclusive mode lost its target; and both non-convert
modes are cfg(target_os = "linux") in the audio thread and answer "DoP
playback is Linux-only" with a stream error. The row could only offer two ways
to fail. Back to ALSA-only. DSD still plays on Windows, converted to PCM.

Exclusive mode became visible the moment phase B published backendIsWasapi,
but the selection arm keys on backend_type == WasapiExclusive and never reads
exclusive_mode. A live toggle that changes nothing, directly under the
BIT-PERFECT header and directly above the thing being measured.

Open audio CD is dropped on Windows: the CDDA backend is mod unsupported there
and answers NoDrive to everything. Open SACD image stays on every platform --
it opens a file, not a drive, and hiding it would have been a bug.

cc
2026-08-27 12:44:20 -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("Albums"), "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()
}
}
}
}
}
}
}
}
}