diff --git a/modules/PopupPanel.qml b/modules/PopupPanel.qml new file mode 100644 index 0000000..8702254 --- /dev/null +++ b/modules/PopupPanel.qml @@ -0,0 +1,92 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import "." as M + +// Shared flyout popup window — slides down from the bar, dismisses on +// click outside. Created on demand via Loader; animates in on creation, +// animates out then emits dismissed() for the Loader to deactivate. +PanelWindow { + id: root + + default property alias content: contentCol.children + required property var screen + required property real anchorX + property real panelWidth: 220 + + signal dismissed() + + visible: true + color: "transparent" + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.exclusiveZone: 0 + WlrLayershell.namespace: "nova-popup" + + anchors.top: true + anchors.left: true + anchors.right: true + anchors.bottom: true + + Component.onCompleted: showAnim.start() + + function dismiss() { + showAnim.stop(); + hideAnim.start(); + } + + // Click outside → dismiss + MouseArea { + anchors.fill: parent + onClicked: root.dismiss() + } + + Item { + id: panel + + x: Math.max(0, Math.min( + Math.round(root.anchorX - contentCol.width / 2), + root.width - contentCol.width + )) + y: 0 + width: contentCol.width + height: contentCol.height + opacity: 0 + + // Eat clicks inside the panel + MouseArea { + anchors.fill: parent + } + + Rectangle { + anchors.fill: parent + color: M.Theme.base01 + opacity: Math.max(M.Theme.barOpacity, 0.85) + topLeftRadius: 0 + topRightRadius: 0 + bottomLeftRadius: M.Theme.radius + bottomRightRadius: M.Theme.radius + } + + Column { + id: contentCol + width: root.panelWidth + topPadding: 4 + bottomPadding: 4 + spacing: 2 + } + } + + ParallelAnimation { + id: showAnim + NumberAnimation { target: panel; property: "opacity"; from: 0; to: 1; duration: 150; easing.type: Easing.OutCubic } + NumberAnimation { target: panel; property: "y"; from: -panel.height; to: 0; duration: 200; easing.type: Easing.OutCubic } + } + + ParallelAnimation { + id: hideAnim + NumberAnimation { target: panel; property: "opacity"; to: 0; duration: 150; easing.type: Easing.InCubic } + NumberAnimation { target: panel; property: "y"; to: -panel.height; duration: 150; easing.type: Easing.InCubic } + onFinished: root.dismissed() + } +} diff --git a/modules/Power.qml b/modules/Power.qml index 4e388f2..a8c7094 100644 --- a/modules/Power.qml +++ b/modules/Power.qml @@ -27,7 +27,7 @@ M.BarIcon { sourceComponent: M.PowerMenu { screen: root.bar.screen anchorX: root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0) - onMenuClosed: menuLoader.active = false + onDismissed: menuLoader.active = false onRunCommand: cmd => { runner.command = cmd; runner.running = true; diff --git a/modules/PowerMenu.qml b/modules/PowerMenu.qml index 5b50de5..a2a042e 100644 --- a/modules/PowerMenu.qml +++ b/modules/PowerMenu.qml @@ -1,152 +1,73 @@ import QtQuick import Quickshell -import Quickshell.Io -import Quickshell.Wayland import "." as M -PanelWindow { +M.PopupPanel { id: menuWindow - required property var screen - required property real anchorX + panelWidth: 180 - signal menuClosed signal runCommand(var cmd) readonly property bool _isNiri: Quickshell.env("NIRI_SOCKET") !== "" function _run(cmd) { runCommand(cmd); - menuClosed(); + dismiss(); } - visible: true - color: "transparent" + Repeater { + model: [ + { label: "Lock", icon: "\uF023", cmd: ["loginctl", "lock-session"], color: M.Theme.base0D }, + { label: "Suspend", icon: "\uF186", cmd: ["systemctl", "suspend"], color: M.Theme.base0E }, + { label: "Logout", icon: "\uF2F5", cmd: menuWindow._isNiri ? ["niri", "msg", "action", "quit"] : ["loginctl", "terminate-user", ""], color: M.Theme.base0A }, + { label: "Reboot", icon: "\uF021", cmd: ["systemctl", "reboot"], color: M.Theme.base09 }, + { label: "Shutdown", icon: "\uF011", cmd: ["systemctl", "poweroff"], color: M.Theme.base08 } + ] - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.exclusiveZone: 0 - WlrLayershell.namespace: "nova-powermenu" + delegate: Item { + id: entry - anchors.top: true - anchors.left: true - anchors.right: true - anchors.bottom: true + required property var modelData + required property int index - MouseArea { - anchors.fill: parent - onClicked: menuWindow.menuClosed() - } + width: menuWindow.panelWidth + height: 32 - Item { - id: panel + Rectangle { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + color: entryArea.containsMouse ? M.Theme.base02 : "transparent" + radius: M.Theme.radius + } - x: Math.max(0, Math.min(Math.round(menuWindow.anchorX - menuCol.width / 2), menuWindow.width - menuCol.width)) - y: 0 + Text { + id: entryIcon + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 12 + text: entry.modelData.icon + color: entry.modelData.color + font.pixelSize: M.Theme.fontSize + 1 + font.family: M.Theme.iconFontFamily + } - width: menuCol.width - height: menuCol.height + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: entryIcon.right + anchors.leftMargin: 10 + text: entry.modelData.label + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + } - MouseArea { - anchors.fill: parent - } - - Rectangle { - anchors.fill: parent - color: M.Theme.base01 - opacity: Math.max(M.Theme.barOpacity, 0.85) - topLeftRadius: 0 - topRightRadius: 0 - bottomLeftRadius: M.Theme.radius - bottomRightRadius: M.Theme.radius - } - - Column { - id: menuCol - - width: 180 - topPadding: 4 - bottomPadding: 4 - spacing: 2 - - Repeater { - model: [ - { - label: "Lock", - icon: "\uF023", - cmd: ["loginctl", "lock-session"], - color: M.Theme.base0D - }, - { - label: "Suspend", - icon: "\uF186", - cmd: ["systemctl", "suspend"], - color: M.Theme.base0E - }, - { - label: "Logout", - icon: "\uF2F5", - cmd: menuWindow._isNiri ? ["niri", "msg", "action", "quit"] : ["loginctl", "terminate-user", ""], - color: M.Theme.base0A - }, - { - label: "Reboot", - icon: "\uF021", - cmd: ["systemctl", "reboot"], - color: M.Theme.base09 - }, - { - label: "Shutdown", - icon: "\uF011", - cmd: ["systemctl", "poweroff"], - color: M.Theme.base08 - } - ] - - delegate: Item { - id: entry - - required property var modelData - required property int index - - width: menuCol.width - height: 32 - - Rectangle { - anchors.fill: parent - anchors.leftMargin: 4 - anchors.rightMargin: 4 - color: entryArea.containsMouse ? M.Theme.base02 : "transparent" - radius: M.Theme.radius - } - - Text { - id: entryIcon - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: 12 - text: entry.modelData.icon - color: entry.modelData.color - font.pixelSize: M.Theme.fontSize + 1 - font.family: M.Theme.iconFontFamily - } - - Text { - anchors.verticalCenter: parent.verticalCenter - anchors.left: entryIcon.right - anchors.leftMargin: 10 - text: entry.modelData.label - color: M.Theme.base05 - font.pixelSize: M.Theme.fontSize - font.family: M.Theme.fontFamily - } - - MouseArea { - id: entryArea - anchors.fill: parent - hoverEnabled: true - onClicked: menuWindow._run(entry.modelData.cmd) - } - } + MouseArea { + id: entryArea + anchors.fill: parent + hoverEnabled: true + onClicked: menuWindow._run(entry.modelData.cmd) } } } diff --git a/modules/Tray.qml b/modules/Tray.qml index a824348..e575b42 100644 --- a/modules/Tray.qml +++ b/modules/Tray.qml @@ -69,7 +69,7 @@ RowLayout { handle: iconItem.modelData.menu screen: root.bar.screen anchorX: iconItem.mapToGlobal(iconItem.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0) - onMenuClosed: { + onDismissed: { menuLoader.active = false; root._activeMenu = null; } diff --git a/modules/TrayMenu.qml b/modules/TrayMenu.qml index 51cfc43..0abc7db 100644 --- a/modules/TrayMenu.qml +++ b/modules/TrayMenu.qml @@ -1,204 +1,137 @@ import QtQuick import Quickshell -import Quickshell.Wayland import "." as M -// Per-icon context menu popup window. -// Covers the screen on the Overlay layer so clicking anywhere outside -// the menu panel dismisses it. Created on demand by Tray.qml delegates. -PanelWindow { +M.PopupPanel { id: menuWindow required property var handle - required property var screen - required property real anchorX - signal menuClosed - - // Current menu level — swapped when entering/leaving submenus property var _currentHandle: handle property var _handleStack: [] - visible: true - color: "transparent" - - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.exclusiveZone: 0 - WlrLayershell.namespace: "nova-traymenu" - - anchors.top: true - anchors.left: true - anchors.right: true - anchors.bottom: true - - // Click outside the menu panel → dismiss - MouseArea { - anchors.fill: parent - onClicked: menuWindow.menuClosed() + QsMenuOpener { + id: opener + menu: menuWindow._currentHandle } - // Menu panel + // Back button (submenus only) Item { - id: panel - - x: Math.max(0, Math.min(Math.round(menuWindow.anchorX - menuCol.width / 2), menuWindow.width - menuCol.width)) - y: 0 - - width: menuCol.width - height: menuCol.height - - // Eat clicks inside the panel - MouseArea { - anchors.fill: parent - } + visible: menuWindow._handleStack.length > 0 + width: menuWindow.panelWidth + height: visible ? 28 : 0 Rectangle { anchors.fill: parent - color: M.Theme.base01 - opacity: Math.max(M.Theme.barOpacity, 0.85) - topLeftRadius: 0 - topRightRadius: 0 - bottomLeftRadius: M.Theme.radius - bottomRightRadius: M.Theme.radius + anchors.leftMargin: 4 + anchors.rightMargin: 4 + color: backArea.containsMouse ? M.Theme.base02 : "transparent" + radius: M.Theme.radius } - Column { - id: menuCol + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 12 + text: "\u2039 Back" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + } - width: 220 - topPadding: 4 - bottomPadding: 4 - spacing: 2 + MouseArea { + id: backArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + const stack = menuWindow._handleStack.slice(); + menuWindow._currentHandle = stack.pop(); + menuWindow._handleStack = stack; + } + } + } - QsMenuOpener { - id: opener - menu: menuWindow._currentHandle + Repeater { + model: opener.children + + delegate: Item { + id: entryItem + + required property QsMenuEntry modelData + + width: menuWindow.panelWidth + height: modelData.isSeparator ? 9 : 28 + + Rectangle { + visible: entryItem.modelData.isSeparator + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 8 + anchors.rightMargin: 8 + height: 1 + color: M.Theme.base03 } - // Back button (submenus only) - Item { - visible: menuWindow._handleStack.length > 0 - width: menuCol.width - height: visible ? 28 : 0 - - Rectangle { - anchors.fill: parent - anchors.leftMargin: 4 - anchors.rightMargin: 4 - color: backArea.containsMouse ? M.Theme.base02 : "transparent" - radius: M.Theme.radius - } - - Text { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: 12 - text: "\u2039 Back" - color: M.Theme.base05 - font.pixelSize: M.Theme.fontSize - font.family: M.Theme.fontFamily - } - - MouseArea { - id: backArea - anchors.fill: parent - hoverEnabled: true - onClicked: { - const stack = menuWindow._handleStack.slice(); - menuWindow._currentHandle = stack.pop(); - menuWindow._handleStack = stack; - } - } + Rectangle { + visible: !entryItem.modelData.isSeparator + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + color: rowArea.containsMouse && entryItem.modelData.enabled ? M.Theme.base02 : "transparent" + radius: M.Theme.radius } - Repeater { - model: opener.children + Image { + id: entryIcon + visible: !entryItem.modelData.isSeparator && entryItem.modelData.icon !== "" + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 12 + width: M.Theme.fontSize + height: M.Theme.fontSize + source: entryItem.modelData.icon + fillMode: Image.PreserveAspectFit + } - delegate: Item { - id: entryItem + Text { + visible: !entryItem.modelData.isSeparator + anchors.verticalCenter: parent.verticalCenter + anchors.left: entryIcon.visible ? entryIcon.right : parent.left + anchors.leftMargin: entryIcon.visible ? 6 : 12 + anchors.right: entryChevron.visible ? entryChevron.left : parent.right + anchors.rightMargin: entryChevron.visible ? 4 : 12 + text: entryItem.modelData.text + color: entryItem.modelData.enabled ? M.Theme.base05 : M.Theme.base03 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + elide: Text.ElideRight + } - required property QsMenuEntry modelData + Text { + id: entryChevron + visible: !entryItem.modelData.isSeparator && entryItem.modelData.hasChildren + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: 12 + text: "\u203A" + color: entryItem.modelData.enabled ? M.Theme.base05 : M.Theme.base03 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + } - width: menuCol.width - height: modelData.isSeparator ? 9 : 28 - - // Separator - Rectangle { - visible: entryItem.modelData.isSeparator - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.right: parent.right - anchors.leftMargin: 8 - anchors.rightMargin: 8 - height: 1 - color: M.Theme.base03 - } - - // Hover highlight - Rectangle { - visible: !entryItem.modelData.isSeparator - anchors.fill: parent - anchors.leftMargin: 4 - anchors.rightMargin: 4 - color: rowArea.containsMouse && entryItem.modelData.enabled ? M.Theme.base02 : "transparent" - radius: M.Theme.radius - } - - // Icon - Image { - id: entryIcon - visible: !entryItem.modelData.isSeparator && entryItem.modelData.icon !== "" - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: 12 - width: M.Theme.fontSize - height: M.Theme.fontSize - source: entryItem.modelData.icon - fillMode: Image.PreserveAspectFit - } - - // Label - Text { - visible: !entryItem.modelData.isSeparator - anchors.verticalCenter: parent.verticalCenter - anchors.left: entryIcon.visible ? entryIcon.right : parent.left - anchors.leftMargin: entryIcon.visible ? 6 : 12 - anchors.right: entryChevron.visible ? entryChevron.left : parent.right - anchors.rightMargin: entryChevron.visible ? 4 : 12 - text: entryItem.modelData.text - color: entryItem.modelData.enabled ? M.Theme.base05 : M.Theme.base03 - font.pixelSize: M.Theme.fontSize - font.family: M.Theme.fontFamily - elide: Text.ElideRight - } - - // Submenu chevron - Text { - id: entryChevron - visible: !entryItem.modelData.isSeparator && entryItem.modelData.hasChildren - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right - anchors.rightMargin: 12 - text: "\u203A" - color: entryItem.modelData.enabled ? M.Theme.base05 : M.Theme.base03 - font.pixelSize: M.Theme.fontSize - font.family: M.Theme.fontFamily - } - - MouseArea { - id: rowArea - anchors.fill: parent - hoverEnabled: true - enabled: !entryItem.modelData.isSeparator && entryItem.modelData.enabled - onClicked: { - if (entryItem.modelData.hasChildren) { - menuWindow._handleStack = menuWindow._handleStack.concat([menuWindow._currentHandle]); - menuWindow._currentHandle = entryItem.modelData; - } else { - entryItem.modelData.triggered(); - menuWindow.menuClosed(); - } - } + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + enabled: !entryItem.modelData.isSeparator && entryItem.modelData.enabled + onClicked: { + if (entryItem.modelData.hasChildren) { + menuWindow._handleStack = menuWindow._handleStack.concat([menuWindow._currentHandle]); + menuWindow._currentHandle = entryItem.modelData; + } else { + entryItem.modelData.triggered(); + menuWindow.dismiss(); } } } diff --git a/modules/qmldir b/modules/qmldir index 0a84d2e..8d06d25 100644 --- a/modules/qmldir +++ b/modules/qmldir @@ -13,6 +13,7 @@ Clock 1.0 Clock.qml Volume 1.0 Volume.qml Tray 1.0 Tray.qml TrayMenu 1.0 TrayMenu.qml +PopupPanel 1.0 PopupPanel.qml PowerMenu 1.0 PowerMenu.qml ScreenCorners 1.0 ScreenCorners.qml Osd 1.0 Osd.qml