extract shared PopupPanel for animated tray/power menus

This commit is contained in:
Damocles 2026-04-12 16:24:33 +02:00
parent 77ce83462d
commit b8ec39f2c9
6 changed files with 249 additions and 302 deletions

92
modules/PopupPanel.qml Normal file
View file

@ -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()
}
}

View file

@ -27,7 +27,7 @@ M.BarIcon {
sourceComponent: M.PowerMenu { sourceComponent: M.PowerMenu {
screen: root.bar.screen screen: root.bar.screen
anchorX: root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0) anchorX: root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0)
onMenuClosed: menuLoader.active = false onDismissed: menuLoader.active = false
onRunCommand: cmd => { onRunCommand: cmd => {
runner.command = cmd; runner.command = cmd;
runner.running = true; runner.running = true;

View file

@ -1,152 +1,73 @@
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import "." as M import "." as M
PanelWindow { M.PopupPanel {
id: menuWindow id: menuWindow
required property var screen panelWidth: 180
required property real anchorX
signal menuClosed
signal runCommand(var cmd) signal runCommand(var cmd)
readonly property bool _isNiri: Quickshell.env("NIRI_SOCKET") !== "" readonly property bool _isNiri: Quickshell.env("NIRI_SOCKET") !== ""
function _run(cmd) { function _run(cmd) {
runCommand(cmd); runCommand(cmd);
menuClosed(); dismiss();
} }
visible: true Repeater {
color: "transparent" 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 delegate: Item {
WlrLayershell.exclusiveZone: 0 id: entry
WlrLayershell.namespace: "nova-powermenu"
anchors.top: true required property var modelData
anchors.left: true required property int index
anchors.right: true
anchors.bottom: true
MouseArea { width: menuWindow.panelWidth
anchors.fill: parent height: 32
onClicked: menuWindow.menuClosed()
}
Item { Rectangle {
id: panel 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)) Text {
y: 0 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 Text {
height: menuCol.height 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 { MouseArea {
anchors.fill: parent id: entryArea
} anchors.fill: parent
hoverEnabled: true
Rectangle { onClicked: menuWindow._run(entry.modelData.cmd)
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)
}
}
} }
} }
} }

View file

@ -69,7 +69,7 @@ RowLayout {
handle: iconItem.modelData.menu handle: iconItem.modelData.menu
screen: root.bar.screen screen: root.bar.screen
anchorX: iconItem.mapToGlobal(iconItem.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0) anchorX: iconItem.mapToGlobal(iconItem.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0)
onMenuClosed: { onDismissed: {
menuLoader.active = false; menuLoader.active = false;
root._activeMenu = null; root._activeMenu = null;
} }

View file

@ -1,204 +1,137 @@
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Wayland
import "." as M import "." as M
// Per-icon context menu popup window. M.PopupPanel {
// Covers the screen on the Overlay layer so clicking anywhere outside
// the menu panel dismisses it. Created on demand by Tray.qml delegates.
PanelWindow {
id: menuWindow id: menuWindow
required property var handle 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 _currentHandle: handle
property var _handleStack: [] property var _handleStack: []
visible: true QsMenuOpener {
color: "transparent" id: opener
menu: menuWindow._currentHandle
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()
} }
// Menu panel // Back button (submenus only)
Item { Item {
id: panel visible: menuWindow._handleStack.length > 0
width: menuWindow.panelWidth
x: Math.max(0, Math.min(Math.round(menuWindow.anchorX - menuCol.width / 2), menuWindow.width - menuCol.width)) height: visible ? 28 : 0
y: 0
width: menuCol.width
height: menuCol.height
// Eat clicks inside the panel
MouseArea {
anchors.fill: parent
}
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
color: M.Theme.base01 anchors.leftMargin: 4
opacity: Math.max(M.Theme.barOpacity, 0.85) anchors.rightMargin: 4
topLeftRadius: 0 color: backArea.containsMouse ? M.Theme.base02 : "transparent"
topRightRadius: 0 radius: M.Theme.radius
bottomLeftRadius: M.Theme.radius
bottomRightRadius: M.Theme.radius
} }
Column { Text {
id: menuCol 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 MouseArea {
topPadding: 4 id: backArea
bottomPadding: 4 anchors.fill: parent
spacing: 2 hoverEnabled: true
onClicked: {
const stack = menuWindow._handleStack.slice();
menuWindow._currentHandle = stack.pop();
menuWindow._handleStack = stack;
}
}
}
QsMenuOpener { Repeater {
id: opener model: opener.children
menu: menuWindow._currentHandle
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) Rectangle {
Item { visible: !entryItem.modelData.isSeparator
visible: menuWindow._handleStack.length > 0 anchors.fill: parent
width: menuCol.width anchors.leftMargin: 4
height: visible ? 28 : 0 anchors.rightMargin: 4
color: rowArea.containsMouse && entryItem.modelData.enabled ? M.Theme.base02 : "transparent"
Rectangle { radius: M.Theme.radius
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;
}
}
} }
Repeater { Image {
model: opener.children 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 { Text {
id: entryItem 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 MouseArea {
height: modelData.isSeparator ? 9 : 28 id: rowArea
anchors.fill: parent
// Separator hoverEnabled: true
Rectangle { enabled: !entryItem.modelData.isSeparator && entryItem.modelData.enabled
visible: entryItem.modelData.isSeparator onClicked: {
anchors.verticalCenter: parent.verticalCenter if (entryItem.modelData.hasChildren) {
anchors.left: parent.left menuWindow._handleStack = menuWindow._handleStack.concat([menuWindow._currentHandle]);
anchors.right: parent.right menuWindow._currentHandle = entryItem.modelData;
anchors.leftMargin: 8 } else {
anchors.rightMargin: 8 entryItem.modelData.triggered();
height: 1 menuWindow.dismiss();
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();
}
}
} }
} }
} }

View file

@ -13,6 +13,7 @@ Clock 1.0 Clock.qml
Volume 1.0 Volume.qml Volume 1.0 Volume.qml
Tray 1.0 Tray.qml Tray 1.0 Tray.qml
TrayMenu 1.0 TrayMenu.qml TrayMenu 1.0 TrayMenu.qml
PopupPanel 1.0 PopupPanel.qml
PowerMenu 1.0 PowerMenu.qml PowerMenu 1.0 PowerMenu.qml
ScreenCorners 1.0 ScreenCorners.qml ScreenCorners 1.0 ScreenCorners.qml
Osd 1.0 Osd.qml Osd 1.0 Osd.qml