From 14778696e9f2ec5bf14130eaf8839481e569b032 Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Sat, 20 Sep 2025 09:30:17 +0200 Subject: [PATCH] custom system tray --- .../quickshell/ii/modules/bar/StyledPopup.qml | 16 +- .config/quickshell/ii/modules/bar/SysTray.qml | 52 ++++- .../quickshell/ii/modules/bar/SysTrayItem.qml | 39 +++- .../quickshell/ii/modules/bar/SysTrayMenu.qml | 218 ++++++++++++++++++ .../ii/modules/bar/SysTrayMenuEntry.qml | 128 ++++++++++ .../ii/modules/common/Appearance.qml | 28 ++- .../modules/common/widgets/RippleButton.qml | 2 +- .../common/widgets/StyledRadioButton.qml | 3 +- 8 files changed, 453 insertions(+), 33 deletions(-) create mode 100644 .config/quickshell/ii/modules/bar/SysTrayMenu.qml create mode 100644 .config/quickshell/ii/modules/bar/SysTrayMenuEntry.qml diff --git a/.config/quickshell/ii/modules/bar/StyledPopup.qml b/.config/quickshell/ii/modules/bar/StyledPopup.qml index 9570da884..68ba609e2 100644 --- a/.config/quickshell/ii/modules/bar/StyledPopup.qml +++ b/.config/quickshell/ii/modules/bar/StyledPopup.qml @@ -1,4 +1,5 @@ import qs.modules.common +import qs.modules.common.widgets import qs.modules.common.functions import QtQuick import QtQuick.Effects @@ -26,6 +27,10 @@ LazyLoader { implicitWidth: popupBackground.implicitWidth + Appearance.sizes.hyprlandGapsOut * 2 + root.popupBackgroundMargin implicitHeight: popupBackground.implicitHeight + Appearance.sizes.hyprlandGapsOut * 2 + root.popupBackgroundMargin + mask: Region { + item: popupBackground + } + exclusionMode: ExclusionMode.Ignore exclusiveZone: 0 margins { @@ -49,15 +54,8 @@ LazyLoader { WlrLayershell.namespace: "quickshell:popup" WlrLayershell.layer: WlrLayer.Overlay - RectangularShadow { - property var target: popupBackground - anchors.fill: target - radius: target.radius - blur: 0.9 * Appearance.sizes.hyprlandGapsOut - offset: Qt.vector2d(0.0, 1.0) - spread: 0.7 - color: Appearance.colors.colShadow - cached: true + StyledRectangularShadow { + target: popupBackground } Rectangle { diff --git a/.config/quickshell/ii/modules/bar/SysTray.qml b/.config/quickshell/ii/modules/bar/SysTray.qml index 8eba7b6db..8da341192 100644 --- a/.config/quickshell/ii/modules/bar/SysTray.qml +++ b/.config/quickshell/ii/modules/bar/SysTray.qml @@ -3,6 +3,7 @@ import qs.modules.common.widgets import QtQuick import QtQuick.Layouts import Quickshell +import Quickshell.Hyprland import Quickshell.Services.SystemTray Item { @@ -14,20 +15,58 @@ Item { property bool trayOverflowOpen: false property bool showSeparator: true property bool showOverflowMenu: true + property var activeMenu: null property list itemsInUserList: SystemTray.items.values.filter(i => (Config.options.bar.tray.pinnedItems.includes(i.id) && i.status !== Status.Passive)) property list itemsNotInUserList: SystemTray.items.values.filter(i => (!Config.options.bar.tray.pinnedItems.includes(i.id) && i.status !== Status.Passive)) property bool invertPins: Config.options.bar.tray.invertPinnedItems property list pinnedItems: invertPins ? itemsNotInUserList : itemsInUserList property list unpinnedItems: invertPins ? itemsInUserList : itemsNotInUserList - onUnpinnedItemsChanged: if (unpinnedItems.length == 0) - root.trayOverflowOpen = false + onUnpinnedItemsChanged: { + if (unpinnedItems.length == 0) root.closeOverflowMenu(); + } + + function grabFocus() { + focusGrab.active = true; + } + + function setExtraWindowAndGrabFocus(window) { + root.activeMenu = window; + root.grabFocus(); + } + + function releaseFocus() { + focusGrab.active = false; + } + + function closeOverflowMenu() { + focusGrab.active = false; + } + + onTrayOverflowOpenChanged: { + if (root.trayOverflowOpen) { + root.grabFocus(); + } + } + + HyprlandFocusGrab { + id: focusGrab + active: false + windows: [trayOverflowLayout.QsWindow?.window, root.activeMenu] + onCleared: { + root.trayOverflowOpen = false; + if (root.activeMenu) { + root.activeMenu.close(); + root.activeMenu = null; + } + } + } GridLayout { id: gridLayout columns: root.vertical ? 1 : -1 anchors.fill: parent - rowSpacing: 6 + rowSpacing: 8 columnSpacing: 15 RippleButton { @@ -60,6 +99,7 @@ Item { } StyledPopup { + id: overflowPopup hoverTarget: trayOverflowButton active: root.trayOverflowOpen popupBackgroundMargin: 300 // This should be plenty... makes sure tooltips don't get cutoff (easily) @@ -79,6 +119,8 @@ Item { item: modelData Layout.fillHeight: !root.vertical Layout.fillWidth: root.vertical + onMenuClosed: root.releaseFocus(); + onMenuOpened: (qsWindow) => root.setExtraWindowAndGrabFocus(qsWindow); } } } @@ -95,6 +137,10 @@ Item { item: modelData Layout.fillHeight: !root.vertical Layout.fillWidth: root.vertical + onMenuClosed: root.releaseFocus(); + onMenuOpened: (qsWindow) => { + root.setExtraWindowAndGrabFocus(qsWindow); + } } } diff --git a/.config/quickshell/ii/modules/bar/SysTrayItem.qml b/.config/quickshell/ii/modules/bar/SysTrayItem.qml index f2bbc39d8..0307ad903 100644 --- a/.config/quickshell/ii/modules/bar/SysTrayItem.qml +++ b/.config/quickshell/ii/modules/bar/SysTrayItem.qml @@ -9,12 +9,13 @@ import Qt5Compat.GraphicalEffects MouseArea { id: root - - property var bar: root.QsWindow.window required property SystemTrayItem item property bool targetMenuOpen: false - hoverEnabled: true + signal menuOpened(qsWindow: var) + signal menuClosed() + + hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton implicitWidth: 20 implicitHeight: 20 @@ -36,16 +37,30 @@ MouseArea { if (Config.options.bar.tray.showItemId) tooltip.content += "\n[" + item.id + "]"; } - QsMenuAnchor { + Loader { id: menu - - menu: root.item.menu - anchor.window: bar - anchor.rect.x: root.x + (Config.options.bar.vertical ? 0 : bar?.width) - anchor.rect.y: root.y + (Config.options.bar.vertical ? bar?.height : 0) - anchor.rect.height: root.height - anchor.rect.width: root.width - anchor.edges: Config.options.bar.bottom ? (Edges.Top | Edges.Left) : (Edges.Bottom | Edges.Right) + function open() { + menu.active = true; + } + active: false + sourceComponent: SysTrayMenu { + Component.onCompleted: this.open(); + trayItemMenuHandle: root.item.menu + anchor { + window: root.QsWindow.window + rect.x: root.x + (Config.options.bar.vertical ? 0 : QsWindow.window?.width) + rect.y: root.y + (Config.options.bar.vertical ? QsWindow.window?.height : 0) + rect.height: root.height + rect.width: root.width + edges: Config.options.bar.bottom ? (Edges.Top | Edges.Left) : (Edges.Bottom | Edges.Right) + gravity: Config.options.bar.bottom ? (Edges.Top | Edges.Left) : (Edges.Bottom | Edges.Right) + } + onMenuOpened: (window) => root.menuOpened(window); + onMenuClosed: { + root.menuClosed(); + menu.active = false; + } + } } IconImage { diff --git a/.config/quickshell/ii/modules/bar/SysTrayMenu.qml b/.config/quickshell/ii/modules/bar/SysTrayMenu.qml new file mode 100644 index 000000000..6caffb41c --- /dev/null +++ b/.config/quickshell/ii/modules/bar/SysTrayMenu.qml @@ -0,0 +1,218 @@ +import qs +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland + +PopupWindow { + id: root + required property QsMenuHandle trayItemMenuHandle + property real popupBackgroundMargin: 0 + + signal menuClosed + signal menuOpened(qsWindow: var) // Correct type is QsWindow, but QML does not like that + + color: "transparent" + property real padding: Appearance.sizes.elevationMargin + + implicitHeight: { + let result = 0; + for (let child of stackView.children) { + result = Math.max(child.implicitHeight, result); + } + return result + popupBackground.padding * 2 + root.padding * 2; + } + implicitWidth: { + let result = 0; + for (let child of stackView.children) { + result = Math.max(child.implicitWidth, result); + } + return result + popupBackground.padding * 2 + root.padding * 2; + } + + function open() { + root.visible = true; + root.menuOpened(root); + } + + function close() { + root.visible = false; + while (stackView.depth > 1) + stackView.pop(); + root.menuClosed(); + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.BackButton | Qt.RightButton + onClicked: event => { + if ((event.button === Qt.BackButton || event.button === Qt.RightButton) && stackView.depth > 1) + stackView.pop(); + } + + StyledRectangularShadow { + target: popupBackground + opacity: popupBackground.opacity + } + + Rectangle { + id: popupBackground + readonly property real padding: 4 + anchors { + left: parent.left + right: parent.right + verticalCenter: Config.options.bar.vertical ? parent.verticalCenter : undefined + top: Config.options.bar.vertical ? undefined : Config.options.bar.bottom ? undefined : parent.top + bottom: Config.options.bar.vertical ? undefined : Config.options.bar.bottom ? parent.bottom : undefined + margins: root.padding + } + + color: Appearance.colors.colLayer0 + radius: Appearance.rounding.windowRounding + border.width: 1 + border.color: Appearance.colors.colLayer0Border + clip: true + + opacity: 0 + Component.onCompleted: opacity = 1 + implicitWidth: stackView.implicitWidth + popupBackground.padding * 2 + implicitHeight: stackView.implicitHeight + popupBackground.padding * 2 + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + Behavior on implicitWidth { + animation: Appearance.animation.elementResize.numberAnimation.createObject(this) + } + + StackView { + id: stackView + anchors { + fill: parent + margins: popupBackground.padding + } + pushEnter: NoAnim {} + pushExit: NoAnim {} + popEnter: NoAnim {} + popExit: NoAnim {} + + implicitWidth: currentItem.implicitWidth + implicitHeight: currentItem.implicitHeight + + initialItem: SubMenu { + handle: root.trayItemMenuHandle + } + } + } + } + + component NoAnim: Transition { + NumberAnimation { + duration: 0 + } + } + + component SubMenu: ColumnLayout { + id: submenu + required property QsMenuHandle handle + property bool isSubMenu: false + property bool shown: false + opacity: shown ? 1 : 0 + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + Component.onCompleted: shown = true + StackView.onActivating: shown = true + StackView.onDeactivating: shown = false + StackView.onRemoved: destroy() + + QsMenuOpener { + id: menuOpener + menu: submenu.handle + } + + spacing: 0 + + Loader { + Layout.fillWidth: true + visible: submenu.isSubMenu + active: visible + sourceComponent: RippleButton { + id: backButton + buttonRadius: popupBackground.radius - popupBackground.padding + horizontalPadding: 12 + implicitWidth: contentItem.implicitWidth + horizontalPadding * 2 + implicitHeight: 36 + + onClicked: stackView.pop() + + contentItem: RowLayout { + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + right: parent.right + leftMargin: backButton.horizontalPadding + rightMargin: backButton.horizontalPadding + } + spacing: 8 + MaterialSymbol { + iconSize: 20 + text: "chevron_left" + } + StyledText { + Layout.fillWidth: true + text: Translation.tr("Back") + } + } + } + } + + Repeater { + id: menuEntriesRepeater + property bool iconColumnNeeded: { + for (let i = 0; i < menuOpener.children.values.length; i++) { + if (menuOpener.children.values[i].icon.length > 0) + return true; + } + return false; + } + property bool specialInteractionColumnNeeded: { + for (let i = 0; i < menuOpener.children.values.length; i++) { + if (menuOpener.children.values[i].buttonType !== QsMenuButtonType.None) + return true; + } + return false; + } + model: menuOpener.children + delegate: SysTrayMenuEntry { + required property QsMenuEntry modelData + forceIconColumn: menuEntriesRepeater.iconColumnNeeded + forceSpecialInteractionColumn: menuEntriesRepeater.specialInteractionColumnNeeded + menuEntry: modelData + + buttonRadius: popupBackground.radius - popupBackground.padding + + onDismiss: root.close() + onOpenSubmenu: handle => { + stackView.push(subMenuComponent.createObject(null, { + handle: handle, + isSubMenu: true + })); + } + } + } + } + + Component { + id: subMenuComponent + SubMenu {} + } +} diff --git a/.config/quickshell/ii/modules/bar/SysTrayMenuEntry.qml b/.config/quickshell/ii/modules/bar/SysTrayMenuEntry.qml new file mode 100644 index 000000000..04d62087f --- /dev/null +++ b/.config/quickshell/ii/modules/bar/SysTrayMenuEntry.qml @@ -0,0 +1,128 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets + +RippleButton { + id: root + required property QsMenuEntry menuEntry + property bool forceIconColumn: false + property bool forceSpecialInteractionColumn: false + readonly property bool hasIcon: menuEntry.icon.length > 0 + readonly property bool hasSpecialInteraction: menuEntry.buttonType !== QsMenuButtonType.None + + signal dismiss() + signal openSubmenu(handle: QsMenuHandle) + + colBackground: menuEntry.isSeparator ? Appearance.m3colors.m3outlineVariant : ColorUtils.transparentize(Appearance.colors.colLayer0) + enabled: !menuEntry.isSeparator + opacity: 1 + + horizontalPadding: 12 + implicitWidth: contentItem.implicitWidth + horizontalPadding * 2 + implicitHeight: menuEntry.isSeparator ? 1 : 36 + Layout.topMargin: menuEntry.isSeparator ? 4 : 0 + Layout.bottomMargin: menuEntry.isSeparator ? 4 : 0 + Layout.fillWidth: true + + Component.onCompleted: { + if (menuEntry.isSeparator) { + root.buttonColor = root.colBackground; + } + } + + releaseAction: () => { + if (menuEntry.hasChildren) { + root.openSubmenu(root.menuEntry); + return; + } + menuEntry.triggered(); + root.dismiss(); + } + altAction: (event) => { // Not hog right-click + event.accepted = false; + } + + contentItem: RowLayout { + id: contentItem + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + right: parent.right + leftMargin: root.horizontalPadding + rightMargin: root.horizontalPadding + } + spacing: 8 + visible: !root.menuEntry.isSeparator + + // Interaction: checkbox or radio button + Item { + visible: root.hasSpecialInteraction || root.forceSpecialInteractionColumn + implicitWidth: 20 + implicitHeight: 20 + + Loader { + anchors.fill: parent + active: root.menuEntry.buttonType === QsMenuButtonType.RadioButton + + sourceComponent: StyledRadioButton { + padding: 0 + checked: root.menuEntry.checkState === Qt.Checked + onCheckedChanged: { + if (checked) root.clicked() + } + } + } + + Loader { + anchors.fill: parent + active: root.menuEntry.buttonType === QsMenuButtonType.CheckBox && root.menuEntry.checkState !== Qt.Unchecked + + sourceComponent: MaterialSymbol { + text: root.menuEntry.checkState === Qt.PartiallyChecked ? "check_indeterminate_small" : "check" + iconSize: 20 + } + } + } + + // Button icon + Item { + visible: root.hasIcon || root.forceIconColumn + implicitWidth: 20 + implicitHeight: 20 + + Loader { + anchors.centerIn: parent + active: root.menuEntry.icon.length > 0 + sourceComponent: IconImage { + asynchronous: true + source: root.menuEntry.icon + implicitSize: 20 + mipmap: true + } + } + } + + StyledText { + id: label + text: root.menuEntry.text + font.pixelSize: Appearance.font.pixelSize.smallie + Layout.fillWidth: true + } + + Loader { + active: root.menuEntry.hasChildren + + sourceComponent: MaterialSymbol { + text: "chevron_right" + iconSize: 20 + } + } + } +} diff --git a/.config/quickshell/ii/modules/common/Appearance.qml b/.config/quickshell/ii/modules/common/Appearance.qml index 3bfdf530d..d63d04800 100644 --- a/.config/quickshell/ii/modules/common/Appearance.qml +++ b/.config/quickshell/ii/modules/common/Appearance.qml @@ -212,6 +212,7 @@ Singleton { property QtObject pixelSize: QtObject { property int smallest: 10 property int smaller: 12 + property int smallie: 13 property int small: 15 property int normal: 16 property int large: 17 @@ -254,14 +255,8 @@ Singleton { easing.bezierCurve: root.animation.elementMove.bezierCurve } } - property Component colorAnimation: Component { - ColorAnimation { - duration: root.animation.elementMove.duration - easing.type: root.animation.elementMove.type - easing.bezierCurve: root.animation.elementMove.bezierCurve - } - } } + property QtObject elementMoveEnter: QtObject { property int duration: 400 property int type: Easing.BezierSpline @@ -275,6 +270,7 @@ Singleton { } } } + property QtObject elementMoveExit: QtObject { property int duration: 200 property int type: Easing.BezierSpline @@ -288,6 +284,7 @@ Singleton { } } } + property QtObject elementMoveFast: QtObject { property int duration: animationCurves.expressiveEffectsDuration property int type: Easing.BezierSpline @@ -304,6 +301,21 @@ Singleton { easing.bezierCurve: root.animation.elementMoveFast.bezierCurve }} } + + property QtObject elementResize: QtObject { + property int duration: 400 + property int type: Easing.BezierSpline + property list bezierCurve: animationCurves.emphasized + property int velocity: 650 + property Component numberAnimation: Component { + NumberAnimation { + duration: root.animation.elementResize.duration + easing.type: root.animation.elementResize.type + easing.bezierCurve: root.animation.elementResize.bezierCurve + } + } + } + property QtObject clickBounce: QtObject { property int duration: 200 property int type: Easing.BezierSpline @@ -315,11 +327,13 @@ Singleton { easing.bezierCurve: root.animation.clickBounce.bezierCurve }} } + property QtObject scroll: QtObject { property int duration: 200 property int type: Easing.BezierSpline property list bezierCurve: animationCurves.standardDecel } + property QtObject menuDecel: QtObject { property int duration: 350 property int type: Easing.OutExpo diff --git a/.config/quickshell/ii/modules/common/widgets/RippleButton.qml b/.config/quickshell/ii/modules/common/widgets/RippleButton.qml index 07e6c5318..498bfc1cb 100644 --- a/.config/quickshell/ii/modules/common/widgets/RippleButton.qml +++ b/.config/quickshell/ii/modules/common/widgets/RippleButton.qml @@ -63,7 +63,7 @@ Button { acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton onPressed: (event) => { if(event.button === Qt.RightButton) { - if (root.altAction) root.altAction(); + if (root.altAction) root.altAction(event); return; } if(event.button === Qt.MiddleButton) { diff --git a/.config/quickshell/ii/modules/common/widgets/StyledRadioButton.qml b/.config/quickshell/ii/modules/common/widgets/StyledRadioButton.qml index a6a63b7b8..ac511ce57 100644 --- a/.config/quickshell/ii/modules/common/widgets/StyledRadioButton.qml +++ b/.config/quickshell/ii/modules/common/widgets/StyledRadioButton.qml @@ -10,7 +10,8 @@ import Quickshell.Services.Pipewire RadioButton { id: root - implicitHeight: contentItem.implicitHeight + 4 * 2 + padding: 4 + implicitHeight: contentItem.implicitHeight + padding * 2 property string description property color activeColor: Appearance?.colors.colPrimary ?? "#685496" property color inactiveColor: Appearance?.m3colors.m3onSurfaceVariant ?? "#45464F"