Files
omarchycn/shell/plugins/bar/widgets/Tray.qml
T
5b2c02dee3 Fix unclickable tray submenus by drilling down inside the popup (#6703)
* Fix unclickable tray submenus by drilling down inside the popup

Clicking a tray menu entry that has children was a silent no-op: the
row called QsMenuEntry.display(), which renders a *platform* menu, and
Quickshell refuses that unless the shell root sets `//@ pragma
UseQApplication` -- shell.qml does not. The log shows "Cannot display
PlatformMenuEntry as quickshell was not started in QApplication mode"
and nothing opens. Apps whose whole menu is submenus, like
radiotray-ng's station list, were unusable.

Adding the pragma would be the wrong fix: it switches the entire shell
from QGuiApplication to QApplication, dragging QtWidgets into the
process and changing application-class behavior for the sake of one
popup -- which would then render as an unstyled platform menu beside
omarchy's own popup styling anyway.

Instead, submenus drill down inside the existing popup. A child
QsMenuEntry inherits QsMenuHandle, so it can feed a nested QsMenuOpener
and render through the same row delegate. Each level keeps its own live
opener on a stack -- a child entry is owned by its parent opener's
model, so collapsing to a single reassigned opener would destroy the
very entry being displayed. A back header row walks out one level; at
the root the menu renders exactly as before, and items without a
DBusMenu still use the platform fallback.

* Destroy submenu openers deepest-first and reset before switching items

resetTrayMenu() destroyed openers front-to-back and only cleared
submenuStack afterward. A deeper opener's menu entry is owned by its
parent's children model, so destroying the parent first could
invalidate an entry a still-live child opener referenced. Clear the
stack before tearing anything down, then destroy deepest-first so a
child is always gone before the parent whose model owns its entry.

openTrayMenu() reassigned activeTrayItem before calling resetTrayMenu().
trayMenuOpener.menu binds to activeTrayItem.menu, so that reassignment
immediately swaps what the root opener's children expose -- invalidating
entries any live submenu opener still referenced, before resetTrayMenu()
got a chance to tear them down. Reset first, then switch items.

Thanks @Copilot for catching both.

* Defer submenu reset until the popup's fade-out actually finishes

onTrayMenuOpenChanged reset the submenu stack the instant trayMenuOpen
went false, but the popup stays visible for the whole 140ms opacity
fade (PopupCard's own visible: open || card.opacity > 0) -- dismissing
from a submenu flashed the root menu mid-fade, and could resize or
reposition the fading popup if the two have different geometry.

Moved the reset to trayMenuPopup's own onVisibleChanged, which only
fires once the fade has genuinely completed. Switching to a different
tray item is unaffected: openTrayMenu() already resets explicitly
before assigning the new item, independent of whether the popup ever
dips to invisible (rapid reopen mid-fade never does).

Thanks @Copilot for catching this.

* Ignore tray menu clicks for a beat after changing submenu level

Changing level swaps the Repeater's model, which rebuilds the row
delegates synchronously -- a fresh row lands under a cursor that hasn't
moved. Submenu clicks used to be silent no-ops, which trained users to
click them twice, so that second click now fires whatever entry took
the spot. On radiotray-ng that means an accidental station switch.

Gate row and back-header clicks for 250ms after each level change. A
deliberate follow-up click is slower than that; a double-click is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Pin the submenu back header above the scrolling menu rows

The back header lived inside the Flickable's Column, so in a submenu
taller than the 420px cap -- exactly the long station list this
drill-down exists for -- scrolling down pushed the only way back off
screen, with no Escape or right-click alternative.

Move it into a pinned Column above the Flickable and account for its
height in the popup's contentHeight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reset the tray menu scroll offset when the drill-down is torn down

Flickable keeps its contentY across a model swap whenever the new
content is still tall enough to hold it. A menu dismissed while
scrolled therefore reopened part-way down with its first entries off
screen: reproducible on any tray app whose root menu outgrows the
420px cap, and now reachable on every app once a long submenu has
been scrolled.

Zero the offset in resetTrayMenu(), which runs both on teardown and
before switching items.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Toni Nowak <t.nowak@ai-flow.no>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:48:27 +02:00

847 lines
29 KiB
QML

import Quickshell
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell.Services.SystemTray
import qs.Commons
import qs.Ui
import "TrayModel.js" as TrayModel
BarWidget {
id: root
moduleName: "omarchy.tray"
property bool expanded: false
property bool managePopupOpen: false
property bool trayMenuOpen: false
property var activeTrayItem: null
property var activeTrayAnchor: null
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property var pinnedIds: settings.pinned instanceof Array ? settings.pinned : []
readonly property var hiddenIds: settings.hidden instanceof Array ? settings.hidden : []
readonly property var pinnedItems: bucket("pinned")
readonly property var drawerItems: bucket("drawer")
readonly property var allItems: bucket("all")
readonly property int drawerCount: drawerItems.length
readonly property int trayItemExtent: Style.bar.iconSlot
readonly property int trayItemGap: 0
readonly property int trayJoinGap: 0
readonly property int drawerExtent: drawerCount > 0 ? drawerCount * trayItemExtent + (drawerCount - 1) * trayItemGap : 0
// Match Waybar's group/tray-expander drawer transition-duration.
readonly property int animationDuration: 600
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
}
function openTrayMenu(item, anchorItem, mouse) {
if (!item || !item.menu) {
var point = anchorItem.QsWindow.contentItem.mapFromItem(anchorItem, mouse.x, mouse.y)
item.display(anchorItem.QsWindow.window, point.x, point.y)
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
}
function trayIconSource(icon) {
// Quickshell already resolves the tray icon into a ready-to-use image://
// URL, including a "?path=" fallback search dir for apps that ship their
// tray icon outside a standard theme (e.g. Steam's flat public/ dir). Hand
// it straight to IconImage; guessing a theme sub-directory here only broke
// apps whose layout didn't match the guess.
return String(icon || "")
}
// Symbolic icons ship a fixed fill (often near-white) that the host is meant
// to recolor to its foreground; detect them by the freedesktop "-symbolic"
// name suffix so they can be tinted instead of rendered as-is.
function iconIsSymbolic(icon) {
var name = String(icon || "").split("?")[0]
return name.slice(-9) === "-symbolic"
}
function trayTooltip(item) {
return item.tooltipTitle || item.title || item.id || ""
}
function classifyItem(item) {
var iid = String(item.id || "")
if (hiddenIds.indexOf(iid) !== -1) return "hidden"
if (pinnedIds.indexOf(iid) !== -1) return "pinned"
return "drawer"
}
function ownedByDedicatedWidget(item) {
var layout = root.bar && root.bar.layoutConfig ? root.bar.layoutConfig : null
return TrayModel.ownedByDedicatedWidget(item, layout)
}
function bucket(category) {
var values = SystemTray.items.values
var result = []
for (var i = 0; i < values.length; i++) {
var item = values[i]
if (item.status === Status.Passive) continue
if (ownedByDedicatedWidget(item)) continue
if (category === "all") {
result.push(item)
continue
}
if (classifyItem(item) === category) result.push(item)
}
return result
}
function persistTrayState(pinned, hidden) {
if (!root.bar || !root.bar.shell || typeof root.bar.shell.updateEntryInline !== "function") return
var id = root.moduleName || "omarchy.tray"
root.bar.shell.updateEntryInline(id, { id: id, pinned: pinned, hidden: hidden })
}
function togglePin(iid) {
var p = pinnedIds.slice(), h = hiddenIds.slice()
var idx = p.indexOf(iid)
if (idx !== -1) p.splice(idx, 1)
else {
p.push(iid)
var hi = h.indexOf(iid)
if (hi !== -1) h.splice(hi, 1)
}
persistTrayState(p, h)
}
function toggleHide(iid) {
var p = pinnedIds.slice(), h = hiddenIds.slice()
var idx = h.indexOf(iid)
if (idx !== -1) h.splice(idx, 1)
else {
h.push(iid)
var pi = p.indexOf(iid)
if (pi !== -1) p.splice(pi, 1)
}
persistTrayState(p, h)
}
visible: pinnedItems.length > 0 || drawerCount > 0
clip: false
implicitWidth: root.vertical ? root.barSize : trayContent.implicitWidth
implicitHeight: root.vertical ? trayContent.implicitHeight : root.barSize
Behavior on revealProgress {
NumberAnimation { duration: root.animationDuration; easing.type: Easing.OutCubic }
}
Loader {
id: trayContent
anchors.fill: parent
sourceComponent: root.vertical ? verticalTray : horizontalTray
}
Component {
id: horizontalTray
Item {
id: horizontalTrayRoot
readonly property int pinnedWidth: pinnedRow.implicitWidth
readonly property int drawerBlockWidth: root.allItems.length > 0 ? expandIcon.implicitWidth + root.drawerExtent : 0
implicitWidth: pinnedWidth + drawerBlockWidth
implicitHeight: root.barSize
// Mask out the empty area the collapsed drawer reserves for its slide-in,
// so hovering it doesn't trigger expand and clicks pass through.
containmentMask: QtObject {
function contains(point: point): bool {
if (point.y < 0 || point.y > horizontalTrayRoot.height) return false
// Drawer reveals leftward; chevron sits at the right end when collapsed
// and slides left as it opens. The visible region starts at the chevron.
var chevronX = root.drawerExtent - root.revealExtent
if (point.x >= chevronX && point.x <= horizontalTrayRoot.drawerBlockWidth) return true
// Pinned items, placed to the right of the drawer block.
var pinnedStart = horizontalTrayRoot.drawerBlockWidth
return point.x >= pinnedStart && point.x <= horizontalTrayRoot.implicitWidth
}
}
Item {
id: drawerArea
x: 0
width: horizontalTrayRoot.drawerBlockWidth
height: root.barSize
visible: root.allItems.length > 0
HoverHandler {
onHoveredChanged: root.expanded = hovered
}
BarIconButton {
id: expandIcon
bar: root.bar
width: implicitWidth
height: implicitHeight
x: root.drawerExtent - root.revealExtent
text: "\uf053"
onPressed: function(button) {
if (button === Qt.RightButton) root.managePopupOpen = !root.managePopupOpen
}
}
Item {
id: trayClip
x: expandIcon.width
anchors.verticalCenter: parent.verticalCenter
width: root.drawerExtent
height: root.barSize
clip: true
Row {
id: trayIcons
x: root.drawerExtent - root.revealExtent
anchors.verticalCenter: parent.verticalCenter
spacing: root.trayItemGap
layer.enabled: true
Repeater {
model: root.drawerItems
TrayItem {}
}
}
}
}
Row {
id: pinnedRow
x: drawerArea.x + horizontalTrayRoot.drawerBlockWidth
anchors.verticalCenter: parent.verticalCenter
spacing: root.trayItemGap
leftPadding: root.pinnedItems.length > 0 && root.allItems.length > 0 ? root.trayJoinGap : 0
Repeater {
model: root.pinnedItems
TrayItem {}
}
}
}
}
Component {
id: verticalTray
Item {
id: verticalTrayRoot
readonly property int pinnedHeight: pinnedCol.implicitHeight
readonly property int drawerBlockHeight: root.allItems.length > 0 ? expandIcon.implicitHeight + root.drawerExtent : 0
implicitWidth: root.barSize
implicitHeight: pinnedHeight + drawerBlockHeight
containmentMask: QtObject {
function contains(point: point): bool {
if (point.x < 0 || point.x > verticalTrayRoot.width) return false
var chevronY = root.drawerExtent - root.revealExtent
if (point.y >= chevronY && point.y <= verticalTrayRoot.drawerBlockHeight) return true
var pinnedStart = verticalTrayRoot.drawerBlockHeight
return point.y >= pinnedStart && point.y <= verticalTrayRoot.implicitHeight
}
}
Item {
id: drawerArea
y: 0
width: root.barSize
height: verticalTrayRoot.drawerBlockHeight
visible: root.allItems.length > 0
HoverHandler {
onHoveredChanged: root.expanded = hovered
}
BarIconButton {
id: expandIcon
bar: root.bar
width: implicitWidth
height: implicitHeight
y: root.drawerExtent - root.revealExtent
text: "\uf053"
textRotation: 90
onPressed: function(button) {
if (button === Qt.RightButton) root.managePopupOpen = !root.managePopupOpen
}
}
Item {
id: trayClip
y: expandIcon.height
anchors.horizontalCenter: parent.horizontalCenter
width: root.barSize
height: root.drawerExtent
clip: true
Column {
id: trayIcons
y: root.drawerExtent - root.revealExtent
anchors.horizontalCenter: parent.horizontalCenter
spacing: root.trayItemGap
layer.enabled: true
Repeater {
model: root.drawerItems
TrayItem {}
}
}
}
}
Column {
id: pinnedCol
y: drawerArea.y + verticalTrayRoot.drawerBlockHeight
anchors.horizontalCenter: parent.horizontalCenter
spacing: root.trayItemGap
topPadding: root.pinnedItems.length > 0 && root.allItems.length > 0 ? root.trayJoinGap : 0
Repeater {
model: root.pinnedItems
TrayItem {}
}
}
}
}
PopupCard {
id: managePopup
anchorItem: root
owner: root
bar: root.bar
open: root.managePopupOpen
contentWidth: managePopup.fittedContentWidth(Style.space(300))
contentHeight: managePopup.fittedContentHeight(manageColumn.implicitHeight)
Column {
id: manageColumn
anchors.fill: parent
spacing: Style.space(8)
Text {
text: "Tray icons"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
}
Text {
text: "Pinned icons stay visible. Hidden icons never show."
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
width: parent.width
}
Text {
visible: root.allItems.length === 0
text: "No tray items reporting."
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
font.italic: true
}
Repeater {
model: root.allItems
delegate: Item {
id: rowRoot
required property var modelData
required property int index
width: manageColumn.width
implicitHeight: 28
readonly property string itemId: String(modelData.id || "")
readonly property string displayName: {
var t = String(modelData.title || "").trim()
if (t) return t
var tt = String(modelData.tooltipTitle || "").trim()
if (tt) return tt
var id = String(modelData.id || "")
var slash = id.lastIndexOf("/")
return slash !== -1 ? id.substring(slash + 1) : (id || "Unknown")
}
readonly property bool isPinned: root.pinnedIds.indexOf(itemId) !== -1
readonly property bool isHidden: root.hiddenIds.indexOf(itemId) !== -1
TrayIcon {
id: rowIcon
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: 16
height: 16
icon: rowRoot.modelData.icon
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.left: rowIcon.right
anchors.leftMargin: Style.space(10)
anchors.right: rowHideBtn.left
anchors.rightMargin: Style.space(8)
text: rowRoot.displayName
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
Button {
id: rowPinBtn
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
iconText: "\uf08d"
text: rowRoot.isPinned ? "Unpin" : "Pin"
foreground: root.foreground
horizontalPadding: 8
verticalPadding: 3
iconSize: Style.font.bodySmall
fontSize: Style.font.bodySmall
onClicked: root.togglePin(rowRoot.itemId)
}
Button {
id: rowHideBtn
anchors.verticalCenter: parent.verticalCenter
anchors.right: rowPinBtn.left
anchors.rightMargin: Style.space(6)
iconText: "\uf06e"
text: rowRoot.isHidden ? "Show" : "Hide"
foreground: root.foreground
horizontalPadding: 8
verticalPadding: 3
iconSize: Style.font.bodySmall
fontSize: Style.font.bodySmall
onClicked: root.toggleHide(rowRoot.itemId)
}
}
}
}
}
QsMenuOpener {
id: trayMenuOpener
menu: root.activeTrayItem ? root.activeTrayItem.menu : null
}
PopupCard {
id: trayMenuPopup
anchorItem: root.activeTrayAnchor || root
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(menuHeaderHeight + trayMenuColumn.implicitHeight, Style.space(420))
// 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
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: menuHeader
visible: root.submenuDepth > 0
width: trayMenuLayout.width
spacing: 0
Item {
id: menuBackRow
width: menuHeader.width
implicitHeight: Style.space(30)
Rectangle {
anchors.fill: parent
radius: Math.max(2, Style.cornerRadius)
color: backMouse.containsMouse ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent"
}
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
}
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
}
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()
}
}
}
Item {
width: menuHeader.width
implicitHeight: Style.space(11)
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
}
}
}
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
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: trayMenuColumn
width: trayMenuFlick.width
spacing: 0
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()
}
}
}
}
}
}
}
}
}
// Renders a tray icon, recoloring symbolic icons to the bar foreground so
// they stay visible on any theme (a raw symbolic icon keeps its baked-in
// fill and disappears against a matching background).
component TrayIcon: Item {
id: trayIconRoot
required property var icon
readonly property bool symbolic: root.iconIsSymbolic(icon)
Image {
id: trayIconImage
anchors.fill: parent
fillMode: Image.PreserveAspectFit
// Decode at physical pixels: IconImage uses the logical size,
// which leaves PNG icons upscaled and blurry on HiDPI displays.
sourceSize.width: Math.round(Math.min(width, height) * Screen.devicePixelRatio)
sourceSize.height: Math.round(Math.min(width, height) * Screen.devicePixelRatio)
source: root.trayIconSource(trayIconRoot.icon)
// Kept as a hidden layer so the effect can sample it as a texture.
visible: !trayIconRoot.symbolic
layer.enabled: trayIconRoot.symbolic
}
MultiEffect {
anchors.fill: trayIconImage
source: trayIconImage
visible: trayIconRoot.symbolic
colorization: 1.0
colorizationColor: root.foreground
}
}
component TrayItem: Item {
id: trayItemRoot
required property var modelData
visible: modelData.status !== Status.Passive
implicitWidth: visible ? root.trayItemExtent : 0
implicitHeight: visible ? root.trayItemExtent : 0
function displayMenu(mouse) {
root.openTrayMenu(trayItemRoot.modelData, trayItemRoot, mouse)
}
TrayIcon {
anchors.centerIn: parent
width: Style.space(12)
height: Style.space(12)
icon: trayItemRoot.modelData.icon
}
MouseArea {
id: mouseArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: if (root.bar) root.bar.showTooltip(trayItemRoot, root.trayTooltip(modelData))
onExited: if (root.bar) root.bar.hideTooltip(trayItemRoot)
onPressed: function(mouse) {
if (mouse.button === Qt.RightButton) {
trayItemRoot.displayMenu(mouse)
mouse.accepted = true
}
}
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) {
mouse.accepted = true
} else if (mouse.button === Qt.MiddleButton) {
trayItemRoot.modelData.secondaryActivate()
} else if (trayItemRoot.modelData.onlyMenu) {
trayItemRoot.displayMenu(mouse)
} else {
trayItemRoot.modelData.activate()
}
}
onWheel: function(wheel) {
trayItemRoot.modelData.scroll(wheel.angleDelta.y, false)
}
}
readonly property bool tooltipHovered: visible && opacity > 0 && mouseArea.containsMouse
}
}