more flyout stuff, tray menus
This commit is contained in:
parent
3a548930e2
commit
59458cade3
4 changed files with 401 additions and 1 deletions
133
TODO-appindicators.md
Normal file
133
TODO-appindicators.md
Normal 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`.
|
||||||
|
|
@ -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,8 +47,24 @@ 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
235
modules/TrayMenu.qml
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue