import QtQuick import QtQuick.Controls import Quickshell import Quickshell.Services.SystemTray import Quickshell.Wayland import Quickshell.Widgets import qs.Common import qs.Widgets Item { id: root property bool isVertical: axis?.isVertical ?? false property var axis: null property var parentWindow: null property var parentScreen: null property real widgetThickness: 30 property real barThickness: 48 property bool isAtBottom: false readonly property real horizontalPadding: SettingsData.dankBarNoBackground ? 2 : Theme.spacingS readonly property var hiddenTrayIds: { const envValue = Quickshell.env("DMS_HIDE_TRAYIDS") || "" return envValue ? envValue.split(",").map(id => id.trim().toLowerCase()) : [] } readonly property var visibleTrayItems: { if (!hiddenTrayIds.length) { return SystemTray.items.values } return SystemTray.items.values.filter(item => { const itemId = item?.id || "" return !hiddenTrayIds.includes(itemId.toLowerCase()) }) } readonly property int calculatedSize: visibleTrayItems.length > 0 ? visibleTrayItems.length * 24 + horizontalPadding * 2 : 0 readonly property real visualWidth: isVertical ? widgetThickness : calculatedSize readonly property real visualHeight: isVertical ? calculatedSize : widgetThickness width: isVertical ? barThickness : visualWidth height: isVertical ? visualHeight : barThickness visible: visibleTrayItems.length > 0 Rectangle { id: visualBackground width: root.visualWidth height: root.visualHeight anchors.centerIn: parent radius: SettingsData.dankBarNoBackground ? 0 : Theme.cornerRadius color: { if (visibleTrayItems.length === 0) { return "transparent"; } if (SettingsData.dankBarNoBackground) { return "transparent"; } const baseColor = Theme.widgetBaseBackgroundColor; return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * Theme.widgetTransparency); } } Loader { id: layoutLoader anchors.centerIn: parent sourceComponent: root.isVertical ? columnComp : rowComp } Component { id: rowComp Row { spacing: 0 Repeater { model: root.visibleTrayItems delegate: Item { id: delegateRoot property var trayItem: modelData property string iconSource: { let icon = trayItem && trayItem.icon; if (typeof icon === 'string' || icon instanceof String) { if (icon === "") { return ""; } if (icon.includes("?path=")) { const split = icon.split("?path="); if (split.length !== 2) { return icon; } const name = split[0]; const path = split[1]; let fileName = name.substring(name.lastIndexOf("/") + 1); if (fileName.startsWith("dropboxstatus")) { fileName = `hicolor/16x16/status/${fileName}`; } return `file://${path}/${fileName}`; } if (icon.startsWith("/") && !icon.startsWith("file://")) { return `file://${icon}`; } return icon; } return ""; } width: 24 height: root.barThickness Rectangle { id: visualContent width: 24 height: 24 anchors.centerIn: parent radius: Theme.cornerRadius color: trayItemArea.containsMouse ? Theme.primaryHover : "transparent" IconImage { id: iconImg anchors.centerIn: parent width: Theme.barIconSize(root.barThickness) height: Theme.barIconSize(root.barThickness) source: delegateRoot.iconSource asynchronous: true smooth: true mipmap: true visible: status === Image.Ready } Text { anchors.centerIn: parent visible: !iconImg.visible text: { const itemId = trayItem?.id || "" if (!itemId) { return "?" } return itemId.charAt(0).toUpperCase() } font.pixelSize: 10 color: Theme.surfaceText } } MouseArea { id: trayItemArea anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: Qt.PointingHandCursor onClicked: (mouse) => { if (!delegateRoot.trayItem) { return; } if (mouse.button === Qt.LeftButton && !delegateRoot.trayItem.onlyMenu) { delegateRoot.trayItem.activate(); return ; } if (delegateRoot.trayItem.hasMenu) { root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVertical, root.axis); } } } } } } } Component { id: columnComp Column { spacing: 0 Repeater { model: root.visibleTrayItems delegate: Item { id: delegateRoot property var trayItem: modelData property string iconSource: { let icon = trayItem && trayItem.icon; if (typeof icon === 'string' || icon instanceof String) { if (icon === "") { return ""; } if (icon.includes("?path=")) { const split = icon.split("?path="); if (split.length !== 2) { return icon; } const name = split[0]; const path = split[1]; let fileName = name.substring(name.lastIndexOf("/") + 1); if (fileName.startsWith("dropboxstatus")) { fileName = `hicolor/16x16/status/${fileName}`; } return `file://${path}/${fileName}`; } if (icon.startsWith("/") && !icon.startsWith("file://")) { return `file://${icon}`; } return icon; } return ""; } width: root.barThickness height: 24 Rectangle { id: visualContent width: 24 height: 24 anchors.centerIn: parent radius: Theme.cornerRadius color: trayItemArea.containsMouse ? Theme.primaryHover : "transparent" IconImage { id: iconImg anchors.centerIn: parent width: Theme.barIconSize(root.barThickness) height: Theme.barIconSize(root.barThickness) source: delegateRoot.iconSource asynchronous: true smooth: true mipmap: true visible: status === Image.Ready } Text { anchors.centerIn: parent visible: !iconImg.visible text: { const itemId = trayItem?.id || "" if (!itemId) { return "?" } return itemId.charAt(0).toUpperCase() } font.pixelSize: 10 color: Theme.surfaceText } } MouseArea { id: trayItemArea anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: Qt.PointingHandCursor onClicked: (mouse) => { if (!delegateRoot.trayItem) { return; } if (mouse.button === Qt.LeftButton && !delegateRoot.trayItem.onlyMenu) { delegateRoot.trayItem.activate(); return ; } if (delegateRoot.trayItem.hasMenu) { root.showForTrayItem(delegateRoot.trayItem, visualContent, parentScreen, root.isAtBottom, root.isVertical, root.axis); } } } } } } } Component { id: trayMenuComponent Rectangle { id: menuRoot property var trayItem: null property var anchorItem: null property var parentScreen: null property bool isAtBottom: false property bool isVertical: false property var axis: null property bool showMenu: false property var menuHandle: null ListModel { id: entryStack } function topEntry() { return entryStack.count ? entryStack.get(entryStack.count - 1).handle : null } function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) { trayItem = item anchorItem = anchor parentScreen = screen isAtBottom = atBottom isVertical = vertical axis = axisObj menuHandle = item?.menu if (parentScreen) { for (var i = 0; i < Quickshell.screens.length; i++) { const s = Quickshell.screens[i] if (s === parentScreen) { menuWindow.screen = s break } } } showMenu = true } function close() { showMenu = false } function showSubMenu(entry) { if (!entry || !entry.hasChildren) return; entryStack.append({ handle: entry }); const h = entry.menu || entry; if (h && typeof h.updateLayout === "function") h.updateLayout(); submenuHydrator.menu = h; submenuHydrator.open(); Qt.callLater(() => submenuHydrator.close()); } function goBack() { if (!entryStack.count) return; entryStack.remove(entryStack.count - 1); } width: 0 height: 0 color: "transparent" PanelWindow { id: menuWindow visible: menuRoot.showMenu && (menuRoot.trayItem?.hasMenu ?? false) WlrLayershell.layer: WlrLayershell.Overlay WlrLayershell.exclusiveZone: -1 WlrLayershell.keyboardFocus: WlrKeyboardFocus.None color: "transparent" anchors { top: true left: true right: true bottom: true } property point anchorPos: Qt.point(screen.width / 2, screen.height / 2) onVisibleChanged: { if (visible) { updatePosition() } } function updatePosition() { if (!menuRoot.anchorItem || !menuRoot.trayItem) { anchorPos = Qt.point(screen.width / 2, screen.height / 2) return } const globalPos = menuRoot.anchorItem.mapToGlobal(0, 0) const screenX = screen.x || 0 const screenY = screen.y || 0 const relativeX = globalPos.x - screenX const relativeY = globalPos.y - screenY const widgetThickness = Math.max(20, 26 + SettingsData.dankBarInnerPadding * 0.6) const effectiveBarThickness = Math.max(widgetThickness + SettingsData.dankBarInnerPadding + 4, Theme.barHeight - 4 - (8 - SettingsData.dankBarInnerPadding)) if (menuRoot.isVertical) { const edge = menuRoot.axis?.edge let targetX if (edge === "left") { targetX = effectiveBarThickness + SettingsData.dankBarSpacing + Theme.popupDistance } else { const popupX = effectiveBarThickness + SettingsData.dankBarSpacing + Theme.popupDistance targetX = screen.width - popupX } anchorPos = Qt.point(targetX, relativeY + menuRoot.anchorItem.height / 2) } else { let targetY if (menuRoot.isAtBottom) { const popupY = effectiveBarThickness + SettingsData.dankBarSpacing + SettingsData.dankBarBottomGap + Theme.popupDistance targetY = screen.height - popupY } else { targetY = effectiveBarThickness + SettingsData.dankBarSpacing + SettingsData.dankBarBottomGap + Theme.popupDistance } anchorPos = Qt.point(relativeX + menuRoot.anchorItem.width / 2, targetY) } } Rectangle { id: menuContainer width: Math.min(500, Math.max(250, menuColumn.implicitWidth + Theme.spacingS * 2)) height: Math.max(40, menuColumn.implicitHeight + Theme.spacingS * 2) x: { if (menuRoot.isVertical) { const edge = menuRoot.axis?.edge if (edge === "left") { const targetX = menuWindow.anchorPos.x return Math.min(menuWindow.screen.width - width - 10, targetX) } else { const targetX = menuWindow.anchorPos.x - width return Math.max(10, targetX) } } else { const left = 10 const right = menuWindow.width - width - 10 const want = menuWindow.anchorPos.x - width / 2 return Math.max(left, Math.min(right, want)) } } y: { if (menuRoot.isVertical) { const top = 10 const bottom = menuWindow.height - height - 10 const want = menuWindow.anchorPos.y - height / 2 return Math.max(top, Math.min(bottom, want)) } else { if (menuRoot.isAtBottom) { const targetY = menuWindow.anchorPos.y - height return Math.max(10, targetY) } else { const targetY = menuWindow.anchorPos.y return Math.min(menuWindow.screen.height - height - 10, targetY) } } } color: Theme.popupBackground() radius: Theme.cornerRadius border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) border.width: 1 opacity: menuRoot.showMenu ? 1 : 0 scale: menuRoot.showMenu ? 1 : 0.85 Rectangle { anchors.fill: parent anchors.topMargin: 4 anchors.leftMargin: 2 anchors.rightMargin: -2 anchors.bottomMargin: -4 radius: parent.radius color: Qt.rgba(0, 0, 0, 0.15) z: parent.z - 1 } QsMenuAnchor { id: submenuHydrator anchor.window: menuWindow } QsMenuOpener { id: rootOpener menu: menuRoot.menuHandle } QsMenuOpener { id: subOpener menu: { const e = menuRoot.topEntry(); return e ? (e.menu || e) : null; } } Column { id: menuColumn width: parent.width - Theme.spacingS * 2 anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.topMargin: Theme.spacingS spacing: 1 Rectangle { visible: entryStack.count > 0 width: parent.width height: 28 radius: Theme.cornerRadius color: backArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" Row { anchors.left: parent.left anchors.leftMargin: Theme.spacingS anchors.verticalCenter: parent.verticalCenter spacing: Theme.spacingXS DankIcon { name: "arrow_back" size: 16 color: Theme.surfaceText anchors.verticalCenter: parent.verticalCenter } StyledText { text: I18n.tr("Back") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText anchors.verticalCenter: parent.verticalCenter } } MouseArea { id: backArea anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: menuRoot.goBack() } } Rectangle { visible: entryStack.count > 0 width: parent.width height: 1 color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) } Repeater { model: entryStack.count ? (subOpener.children ? subOpener.children : (menuRoot.topEntry()?.children || [])) : rootOpener.children Rectangle { property var menuEntry: modelData width: menuColumn.width height: menuEntry?.isSeparator ? 1 : 28 radius: menuEntry?.isSeparator ? 0 : Theme.cornerRadius color: { if (menuEntry?.isSeparator) { return Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) } return itemArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" } MouseArea { id: itemArea anchors.fill: parent enabled: !menuEntry?.isSeparator && (menuEntry?.enabled !== false) cursorShape: Qt.PointingHandCursor onClicked: { if (!menuEntry || menuEntry.isSeparator) return; if (menuEntry.hasChildren) { menuRoot.showSubMenu(menuEntry); } else { if (typeof menuEntry.activate === "function") { menuEntry.activate(); } else if (typeof menuEntry.triggered === "function") { menuEntry.triggered(); } Qt.createQmlObject('import QtQuick; Timer { interval: 80; running: true; repeat: false; onTriggered: menuRoot.close() }', menuRoot); } } } Row { anchors.left: parent.left anchors.leftMargin: Theme.spacingS anchors.right: parent.right anchors.rightMargin: Theme.spacingS anchors.verticalCenter: parent.verticalCenter spacing: Theme.spacingXS visible: !menuEntry?.isSeparator Rectangle { width: 16 height: 16 anchors.verticalCenter: parent.verticalCenter visible: menuEntry?.buttonType !== undefined && menuEntry.buttonType !== 0 radius: menuEntry?.buttonType === 2 ? 8 : 2 border.width: 1 border.color: Theme.outline color: "transparent" Rectangle { anchors.centerIn: parent width: parent.width - 6 height: parent.height - 6 radius: parent.radius - 3 color: Theme.primary visible: menuEntry?.checkState === 2 } DankIcon { anchors.centerIn: parent name: "check" size: 10 color: Theme.primaryText visible: menuEntry?.buttonType === 1 && menuEntry?.checkState === 2 } } Item { width: 16 height: 16 anchors.verticalCenter: parent.verticalCenter visible: menuEntry?.icon && menuEntry.icon !== "" Image { anchors.fill: parent source: menuEntry?.icon || "" sourceSize.width: 16 sourceSize.height: 16 fillMode: Image.PreserveAspectFit smooth: true } } StyledText { text: menuEntry?.text || "" font.pixelSize: Theme.fontSizeSmall color: (menuEntry?.enabled !== false) ? Theme.surfaceText : Theme.surfaceTextMedium elide: Text.ElideRight anchors.verticalCenter: parent.verticalCenter width: Math.max(150, parent.width - 64) wrapMode: Text.NoWrap } Item { width: 16 height: 16 anchors.verticalCenter: parent.verticalCenter DankIcon { anchors.centerIn: parent name: "chevron_right" size: 14 color: Theme.surfaceText visible: menuEntry?.hasChildren ?? false } } } } } } Behavior on opacity { NumberAnimation { duration: Theme.mediumDuration easing.type: Theme.emphasizedEasing } } Behavior on scale { NumberAnimation { duration: Theme.mediumDuration easing.type: Theme.emphasizedEasing } } } MouseArea { anchors.fill: parent z: -1 onClicked: menuRoot.close() } } } } property var currentTrayMenu: null function showForTrayItem(item, anchor, screen, atBottom, vertical, axisObj) { if (currentTrayMenu) { currentTrayMenu.destroy() } currentTrayMenu = trayMenuComponent.createObject(null) if (currentTrayMenu) { currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj) } } }