diff --git a/TODO-appindicators.md b/TODO-appindicators.md new file mode 100644 index 0000000..73ae76d --- /dev/null +++ b/TODO-appindicators.md @@ -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`. diff --git a/modules/Tray.qml b/modules/Tray.qml index ae33beb..d3bf5aa 100644 --- a/modules/Tray.qml +++ b/modules/Tray.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Services.SystemTray +import "." as M RowLayout { id: root @@ -25,6 +26,20 @@ RowLayout { 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 { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton @@ -32,10 +47,26 @@ RowLayout { if (mouse.button === Qt.LeftButton) { iconItem.modelData.activate(); } 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 + } + } } } } diff --git a/modules/TrayMenu.qml b/modules/TrayMenu.qml new file mode 100644 index 0000000..b65d6c7 --- /dev/null +++ b/modules/TrayMenu.qml @@ -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(); + } + } + } + } + } + } + } +} diff --git a/modules/qmldir b/modules/qmldir index 9f995c4..4805e2f 100644 --- a/modules/qmldir +++ b/modules/qmldir @@ -9,6 +9,7 @@ WindowTitle 1.0 WindowTitle.qml Clock 1.0 Clock.qml Volume 1.0 Volume.qml Tray 1.0 Tray.qml +TrayMenu 1.0 TrayMenu.qml Battery 1.0 Battery.qml Mpris 1.0 Mpris.qml Network 1.0 Network.qml