more flyout stuff, tray menus

This commit is contained in:
Damocles 2026-04-12 11:35:03 +02:00
parent 3a548930e2
commit 59458cade3
4 changed files with 401 additions and 1 deletions

133
TODO-appindicators.md Normal file
View file

@ -0,0 +1,133 @@
# TODO: App indicator (system tray) improvements
## Current state
`modules/Tray.qml` renders tray icons and handles left/right click, but:
1. **Right-click context menu is broken** — calls `modelData.display(root.bar, mouse.x, mouse.y)` which is not a valid Quickshell `SystemTrayItem` method and does nothing.
2. **No tooltips**`SystemTrayItem` exposes `tooltipTitle` and `tooltipDescription` but they are not shown.
3. **Secondary activation** — right-click currently tries to show a menu; for items without a menu, `secondaryActivate()` should be called instead.
---
## Quickshell API reference
```qml
// Key SystemTrayItem properties:
modelData.id // string — unique app identifier
modelData.icon // url — icon source
modelData.title // string — display name
modelData.tooltipTitle // string — tooltip heading
modelData.tooltipDescription // string — tooltip body
modelData.menu // QsMenuHandle | null — context menu (null if app has none)
// Methods:
modelData.activate() // primary action (left click)
modelData.secondaryActivate() // secondary action (middle click or right click when no menu)
// Menu traversal:
QsMenuOpener { id: opener; menu: someQsMenuHandle }
// opener.children → list of QsMenuEntry
// entry.text, entry.icon, entry.enabled, entry.isSeparator
// entry.hasChildren → true if entry is a submenu
// entry.triggered() → invoke the action
// entry as QsMenuHandle → pass back to QsMenuOpener for submenu
```
---
## What needs to be done
### 1. Tooltips (easy)
Add a `HoverHandler` + flyout to each tray icon delegate.
Use `FlyoutState` the same way BarSection does it:
```qml
HoverHandler {
onHoveredChanged: {
const tip = [iconItem.modelData.tooltipTitle, iconItem.modelData.tooltipDescription]
.filter(s => s).join("\n") || iconItem.modelData.title;
if (hovered && tip) {
M.FlyoutState.text = tip;
M.FlyoutState.itemX = iconItem.mapToGlobal(iconItem.width / 2, 0).x;
M.FlyoutState.visible = true;
} else if (!hovered) {
M.FlyoutState.visible = false;
}
}
}
```
### 2. Context menus (harder)
The challenge: menus need their own window/panel to render in (can't render inside the bar's PanelWindow without clipping). Celestia-shell solves this with a dedicated popout PanelWindow per screen.
**Approach A — Extend the Flyout system (recommended)**
Extend `FlyoutState` to optionally carry a `QsMenuHandle` instead of just text. When a tray icon is right-clicked, set `FlyoutState.menuHandle = modelData.menu`. In `Flyout.qml`, detect whether to render the text tooltip or a menu:
```qml
// FlyoutState additions:
property var menuHandle: null // QsMenuHandle or null
property bool isMenu: false
// In Flyout.qml: show either the text box or the menu panel
Loader {
active: M.FlyoutState.isMenu && M.FlyoutState.menuHandle !== null
sourceComponent: TrayMenuPanel { handle: M.FlyoutState.menuHandle }
}
```
`TrayMenuPanel` would use `QsMenuOpener` to iterate the menu, render items as a `Column` of clickable rows, and handle submenus recursively (push/pop a StackView like celestia-shell does, or use a simple recursive Loader).
**Approach B — Per-icon popup window**
Each tray delegate instantiates a small PanelWindow on demand:
```qml
// Only created on right-click
Loader {
id: menuLoader
active: false
sourceComponent: TrayMenuWindow {
handle: iconItem.modelData.menu
screen: root.bar.screen
anchorX: ...
}
}
MouseArea {
onClicked: mouse => {
if (mouse.button === Qt.RightButton && iconItem.modelData.menu)
menuLoader.active = true;
}
}
```
Simpler isolation but harder to manage dismiss/close (clicking outside).
### 3. Right-click fallback
If `modelData.menu === null`, right-click should call `modelData.secondaryActivate()` rather than trying to open a menu.
```qml
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
modelData.activate();
} else if (mouse.button === Qt.RightButton) {
if (modelData.menu)
// open menu (Approach A or B above)
else
modelData.secondaryActivate();
}
}
```
---
## Reference
Celestia-shell implements a full menu renderer at:
- `modules/bar/popouts/TrayMenu.qml``StackView`-based menu with submenu push/pop, separator support, icon+label rows, back button for submenus
- `modules/bar/components/TrayItem.qml` — per-item delegate
- The menu is shown via `popouts.currentName = "traymenu{index}"` in the popout panel system
The key QML types needed: `QsMenuOpener`, `QsMenuHandle`, `QsMenuEntry` — all from `import Quickshell`.

View file

@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import Quickshell
import Quickshell.Services.SystemTray import Quickshell.Services.SystemTray
import "." as M
RowLayout { RowLayout {
id: root id: root
@ -25,6 +26,20 @@ RowLayout {
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
} }
HoverHandler {
onHoveredChanged: {
const tip = [iconItem.modelData.tooltipTitle, iconItem.modelData.tooltipDescription]
.filter(s => s).join("\n") || iconItem.modelData.title;
if (hovered && tip) {
M.FlyoutState.text = tip;
M.FlyoutState.itemX = iconItem.mapToGlobal(iconItem.width / 2, 0).x;
M.FlyoutState.visible = true;
} else if (!hovered) {
M.FlyoutState.visible = false;
}
}
}
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton acceptedButtons: Qt.LeftButton | Qt.RightButton
@ -32,10 +47,26 @@ RowLayout {
if (mouse.button === Qt.LeftButton) { if (mouse.button === Qt.LeftButton) {
iconItem.modelData.activate(); iconItem.modelData.activate();
} else if (mouse.button === Qt.RightButton) { } else if (mouse.button === Qt.RightButton) {
iconItem.modelData.display(root.bar, mouse.x, mouse.y); if (iconItem.modelData.menu) {
menuLoader.active = true;
} else {
iconItem.modelData.secondaryActivate();
}
} }
} }
} }
// Per-icon context menu window, created on demand
Loader {
id: menuLoader
active: false
sourceComponent: M.TrayMenu {
handle: iconItem.modelData.menu
screen: root.bar.screen
anchorX: iconItem.mapToGlobal(iconItem.width / 2, 0).x
onClosed: menuLoader.active = false
}
}
} }
} }
} }

235
modules/TrayMenu.qml Normal file
View file

@ -0,0 +1,235 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import "." as M
// Per-icon context menu popup window.
// Covers the full screen on the Overlay layer so clicking anywhere outside
// the menu panel dismisses it. Created on demand by Tray.qml delegates.
PanelWindow {
id: menuWindow
required property QsMenuHandle handle
required property var screen
// Global x of the icon center (from mapToGlobal), used to position the panel
required property real anchorX
signal closed()
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
// -1 = ignore exclusive zones so this covers the full screen including the bar
WlrLayershell.exclusiveZone: -1
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.closed()
}
// Menu panel
Item {
id: panel
x: Math.max(0, Math.min(
Math.round(menuWindow.anchorX - menuStack.implicitWidth / 2),
menuWindow.width - menuStack.implicitWidth
))
y: M.Theme.barHeight
implicitWidth: menuStack.implicitWidth
implicitHeight: menuStack.implicitHeight
width: implicitWidth
height: implicitHeight
// Eat clicks inside the panel so they don't reach the dismiss area above
MouseArea {
anchors.fill: parent
}
Rectangle {
anchors.fill: parent
color: M.Theme.base01
opacity: M.Theme.barOpacity
radius: M.Theme.radius
border.color: M.Theme.base02
border.width: 1
}
StackView {
id: menuStack
implicitWidth: currentItem ? currentItem.implicitWidth : 0
implicitHeight: currentItem ? currentItem.implicitHeight : 0
// Push the root page once the stack is ready
Component.onCompleted: {
menuStack.push(menuPageComp, { handle: menuWindow.handle, isRoot: true }, StackView.Immediate);
}
pushEnter: Transition {}
pushExit: Transition {}
popEnter: Transition {}
popExit: Transition {}
}
}
// Reusable menu page component used for both root and submenus
Component {
id: menuPageComp
Column {
id: page
required property QsMenuHandle handle
property bool isRoot: false
implicitWidth: 220
topPadding: 4
bottomPadding: 4
spacing: 2
QsMenuOpener {
id: opener
menu: page.handle
}
// Back button (shown only in submenus)
Item {
visible: !page.isRoot
implicitWidth: page.implicitWidth
implicitHeight: 28
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: " " + qsTr("Back")
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
}
MouseArea {
id: backArea
anchors.fill: parent
hoverEnabled: true
onClicked: menuStack.pop()
}
}
Repeater {
model: opener.children
delegate: Item {
id: entryItem
required property QsMenuEntry modelData
implicitWidth: page.implicitWidth
implicitHeight: modelData.isSeparator ? 9 : 28
// Separator line
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: ""
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) {
menuStack.push(menuPageComp, {
handle: entryItem.modelData,
isRoot: false
}, StackView.Immediate);
} else {
entryItem.modelData.triggered();
menuWindow.closed();
}
}
}
}
}
}
}
}

View file

@ -9,6 +9,7 @@ WindowTitle 1.0 WindowTitle.qml
Clock 1.0 Clock.qml 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
Battery 1.0 Battery.qml Battery 1.0 Battery.qml
Mpris 1.0 Mpris.qml Mpris 1.0 Mpris.qml
Network 1.0 Network.qml Network 1.0 Network.qml