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 ownedByOmarchy(item) { var layout = root.bar && root.bar.layoutConfig ? root.bar.layoutConfig : null return TrayModel.ownedByOmarchy(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 (ownedByOmarchy(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 } }