diff --git a/shell/plugins/bar/widgets/Tray.qml b/shell/plugins/bar/widgets/Tray.qml index 29487fc2..77657a30 100644 --- a/shell/plugins/bar/widgets/Tray.qml +++ b/shell/plugins/bar/widgets/Tray.qml @@ -33,6 +33,82 @@ BarWidget { property real revealProgress: expanded ? 1 : 0 readonly property real revealExtent: drawerExtent * revealProgress + // Submenu drill-down state. QsMenuEntry.display() renders a *platform* menu, + // which Quickshell refuses unless the shell root sets `//@ pragma + // UseQApplication` - omarchy's shell.qml does not, so every submenu click was + // a silent no-op ("Cannot display PlatformMenuEntry as quickshell was not + // started in QApplication mode" in the shell log) and apps whose whole UI is + // submenus, e.g. radiotray-ng's station list, were unusable. QsMenuEntry + // inherits QsMenuHandle, so a child entry can feed a nested QsMenuOpener and + // render inside this popup instead of going through the platform. Each level + // keeps its own live opener: a child entry is owned by its parent opener's + // model, so collapsing the stack to a single opener would destroy the very + // entry being displayed (submenu turns up empty). + property var submenuStack: [] + readonly property int submenuDepth: submenuStack.length + readonly property string currentTitle: submenuDepth > 0 ? submenuStack[submenuDepth - 1].title : "" + readonly property var currentChildren: submenuDepth > 0 + ? submenuStack[submenuDepth - 1].opener.children + : trayMenuOpener.children + + // Changing level rebuilds the row delegates synchronously, so the next + // row lands under a cursor that hasn't moved. Submenu clicks used to be + // silent no-ops, which trained users to click them twice, and that second + // click would now fire whatever entry took the spot. Ignore row clicks for + // a beat after each level change; a deliberate follow-up click is slower. + property bool menuLevelSettling: false + + Component { + id: submenuOpenerComponent + QsMenuOpener {} + } + + Timer { + id: menuLevelSettleTimer + interval: 250 + onTriggered: root.menuLevelSettling = false + } + + function settleMenuLevel() { + menuLevelSettling = true + menuLevelSettleTimer.restart() + } + + function resetTrayMenu() { + menuLevelSettling = false + menuLevelSettleTimer.stop() + // Flickable keeps its offset across a model swap whenever the new content + // is still tall enough to hold it, so a menu dismissed while scrolled + // would otherwise reopen part-way down with its first entries off screen. + trayMenuFlick.contentY = 0 + // Clear the reactive stack before tearing anything down, so no binding can + // read a partially-destroyed opener while this runs. Then destroy deepest + // first: an inner opener's menu entry is owned by its parent's children + // model, so destroying a parent first would invalidate an entry a still- + // live child opener references. + var openers = submenuStack + submenuStack = [] + for (var i = openers.length - 1; i >= 0; i--) openers[i].opener.destroy() + } + + function enterSubmenu(entry, title) { + var opener = submenuOpenerComponent.createObject(root, { menu: entry }) + if (!opener) return + var stack = submenuStack.slice() + stack.push({ opener: opener, title: title }) + submenuStack = stack + settleMenuLevel() + } + + function leaveSubmenu() { + if (submenuStack.length === 0) return + var stack = submenuStack.slice() + var top = stack.pop() + submenuStack = stack + top.opener.destroy() + settleMenuLevel() + } + function close() { managePopupOpen = false trayMenuOpen = false @@ -45,6 +121,11 @@ BarWidget { return } + // Reset before switching items: trayMenuOpener.menu binds to + // activeTrayItem.menu, so assigning a new item invalidates the old root's + // children immediately, before any nested opener referencing them would + // otherwise get torn down. + resetTrayMenu() activeTrayItem = item activeTrayAnchor = anchorItem trayMenuOpen = true @@ -442,133 +523,234 @@ BarWidget { owner: root bar: root.bar open: root.trayMenuOpen + // The card fades out over 140ms (visible stays true for that whole time -- + // see PopupCard's own visible: open || card.opacity > 0), so resetting on + // "open" would swap a live submenu for the root menu mid-fade: a visible + // flash, and a resize/reposition if the two have different geometry. Wait + // for the fade to actually finish. Switching to a different tray item + // still resets immediately, from openTrayMenu() itself. + onVisibleChanged: if (!visible) root.resetTrayMenu() padding: Style.space(8) borderColor: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.45) contentWidth: trayMenuPopup.fittedContentWidth(Style.space(232)) - contentHeight: trayMenuPopup.fittedContentHeight(trayMenuColumn.implicitHeight, Style.space(420)) + contentHeight: trayMenuPopup.fittedContentHeight(menuHeaderHeight + trayMenuColumn.implicitHeight, Style.space(420)) - Flickable { - id: trayMenuFlick + // Column skips invisible children but keeps reporting their height, so + // read the header's extent through its own visibility. + readonly property int menuHeaderHeight: menuHeader.visible ? menuHeader.implicitHeight : 0 + + Column { + id: trayMenuLayout anchors.fill: parent - contentWidth: width - contentHeight: trayMenuColumn.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds - flickableDirection: Flickable.VerticalFlick - interactive: contentHeight > height - - ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + spacing: 0 + // Header for a drilled-into submenu: names where we are and walks back + // out. Pinned above the Flickable rather than scrolling with the rows, + // so the way back stays reachable in a submenu taller than the card. + // Only present below the root level, so the root menu is unchanged. Column { - id: trayMenuColumn - width: trayMenuFlick.width + id: menuHeader + visible: root.submenuDepth > 0 + width: trayMenuLayout.width spacing: 0 - Repeater { - model: trayMenuOpener.children + Item { + id: menuBackRow + width: menuHeader.width + implicitHeight: Style.space(30) - delegate: Item { - id: menuRow - required property var modelData - required property int index + Rectangle { + anchors.fill: parent + radius: Math.max(2, Style.cornerRadius) + color: backMouse.containsMouse ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent" + } - readonly property string rowText: String(modelData.text || "") - readonly property string activeTitle: root.activeTrayItem ? String(root.activeTrayItem.title || root.activeTrayItem.id || "") : "" - readonly property bool rootTitleEntry: index === 0 && modelData.hasChildren && rowText.toLowerCase() === activeTitle.toLowerCase() - readonly property bool leadingSeparator: modelData.isSeparator && index <= 1 - readonly property bool hiddenRow: rootTitleEntry || leadingSeparator + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + width: Style.space(22) + horizontalAlignment: Text.AlignHCenter + text: "\u2039" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } - visible: !hiddenRow - width: trayMenuColumn.width - implicitHeight: hiddenRow ? 0 : (modelData.isSeparator ? Style.space(11) : Style.space(30)) - opacity: modelData.enabled ? 1.0 : 0.45 + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Style.space(28) + anchors.right: parent.right + anchors.rightMargin: Style.space(10) + text: root.currentTitle + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } - Rectangle { - visible: menuRow.modelData.isSeparator - anchors.left: parent.left - anchors.leftMargin: Style.space(10) - anchors.right: parent.right - anchors.rightMargin: Style.space(10) - anchors.verticalCenter: parent.verticalCenter - height: 1 - color: Color.popups.border - opacity: 0.45 + MouseArea { + id: backMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (root.menuLevelSettling) return + // Reset before the model swap so the parent level shows from + // the top (same ordering as the row delegate below). + trayMenuFlick.contentY = 0 + root.leaveSubmenu() } + } + } - Rectangle { - visible: !menuRow.modelData.isSeparator - anchors.fill: parent - radius: Math.max(2, Style.cornerRadius) - color: rowMouse.containsMouse && menuRow.modelData.enabled ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent" - } + Item { + width: menuHeader.width + implicitHeight: Style.space(11) - Text { - visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - width: Style.space(22) - horizontalAlignment: Text.AlignHCenter - text: menuRow.modelData.checkState === Qt.Checked ? "\uf00c" : "" - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - } + Rectangle { + anchors.left: parent.left + anchors.leftMargin: Style.space(10) + anchors.right: parent.right + anchors.rightMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + height: 1 + color: Color.popups.border + opacity: 0.45 + } + } + } - Image { - id: menuIcon - visible: !menuRow.modelData.isSeparator && String(menuRow.modelData.icon || "") !== "" - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: Style.space(24) - width: Style.space(16) - height: Style.space(16) - fillMode: Image.PreserveAspectFit - // Decode at physical pixels: IconImage uses the logical size, - // which leaves PNG icons upscaled and blurry on HiDPI displays. - sourceSize.width: width * Screen.devicePixelRatio - sourceSize.height: height * Screen.devicePixelRatio - source: menuRow.modelData.icon - } + Flickable { + id: trayMenuFlick + width: trayMenuLayout.width + height: trayMenuLayout.height - trayMenuPopup.menuHeaderHeight + contentWidth: width + contentHeight: trayMenuColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + interactive: contentHeight > height - Text { - visible: !menuRow.modelData.isSeparator - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: menuIcon.visible ? Style.space(46) : Style.space(28) - anchors.right: submenuGlyph.left - anchors.rightMargin: Style.space(8) - text: menuRow.rowText - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - elide: Text.ElideRight - } + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - Text { - id: submenuGlyph - visible: !menuRow.modelData.isSeparator && menuRow.modelData.hasChildren - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right - anchors.rightMargin: Style.space(10) - text: "\u203a" - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - } + Column { + id: trayMenuColumn + width: trayMenuFlick.width + spacing: 0 - MouseArea { - id: rowMouse - anchors.fill: parent - hoverEnabled: true - enabled: !menuRow.modelData.isSeparator && menuRow.modelData.enabled - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: { - if (menuRow.modelData.hasChildren) { - var point = menuRow.QsWindow.contentItem.mapFromItem(menuRow, menuRow.width, menuRow.height / 2) - menuRow.modelData.display(menuRow.QsWindow.window, point.x, point.y) - } else { - menuRow.modelData.triggered() - root.close() + Repeater { + model: root.currentChildren + + delegate: Item { + id: menuRow + required property var modelData + required property int index + + readonly property string rowText: String(modelData.text || "") + readonly property string activeTitle: root.activeTrayItem ? String(root.activeTrayItem.title || root.activeTrayItem.id || "") : "" + // Both only ever describe the root menu; inside a submenu the first + // rows are real entries and must not be swallowed. + readonly property bool atRoot: root.submenuDepth === 0 + readonly property bool rootTitleEntry: atRoot && index === 0 && modelData.hasChildren && rowText.toLowerCase() === activeTitle.toLowerCase() + readonly property bool leadingSeparator: atRoot && modelData.isSeparator && index <= 1 + readonly property bool hiddenRow: rootTitleEntry || leadingSeparator + + visible: !hiddenRow + width: trayMenuColumn.width + implicitHeight: hiddenRow ? 0 : (modelData.isSeparator ? Style.space(11) : Style.space(30)) + opacity: modelData.enabled ? 1.0 : 0.45 + + Rectangle { + visible: menuRow.modelData.isSeparator + anchors.left: parent.left + anchors.leftMargin: Style.space(10) + anchors.right: parent.right + anchors.rightMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + height: 1 + color: Color.popups.border + opacity: 0.45 + } + + Rectangle { + visible: !menuRow.modelData.isSeparator + anchors.fill: parent + radius: Math.max(2, Style.cornerRadius) + color: rowMouse.containsMouse && menuRow.modelData.enabled ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent" + } + + Text { + visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + width: Style.space(22) + horizontalAlignment: Text.AlignHCenter + text: menuRow.modelData.checkState === Qt.Checked ? "\uf00c" : "" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Image { + id: menuIcon + visible: !menuRow.modelData.isSeparator && String(menuRow.modelData.icon || "") !== "" + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Style.space(24) + width: Style.space(16) + height: Style.space(16) + fillMode: Image.PreserveAspectFit + // Decode at physical pixels: IconImage uses the logical size, + // which leaves PNG icons upscaled and blurry on HiDPI displays. + sourceSize.width: width * Screen.devicePixelRatio + sourceSize.height: height * Screen.devicePixelRatio + source: menuRow.modelData.icon + } + + Text { + visible: !menuRow.modelData.isSeparator + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: menuIcon.visible ? Style.space(46) : Style.space(28) + anchors.right: submenuGlyph.left + anchors.rightMargin: Style.space(8) + text: menuRow.rowText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } + + Text { + id: submenuGlyph + visible: !menuRow.modelData.isSeparator && menuRow.modelData.hasChildren + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Style.space(10) + text: "\u203a" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + enabled: !menuRow.modelData.isSeparator && menuRow.modelData.enabled + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (root.menuLevelSettling) return + if (menuRow.modelData.hasChildren) { + // Reset scroll BEFORE swapping the model: the swap destroys + // this delegate synchronously and ids stop resolving after. + trayMenuFlick.contentY = 0 + root.enterSubmenu(menuRow.modelData, menuRow.rowText) + } else { + menuRow.modelData.triggered() + root.close() + } } } }