diff --git a/README.md b/README.md index 86d1a06..d549eac 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,9 @@ programs.nova-shell.enable = true; Sure, why not. It can't get much worse. -## License +## Copyright -GPLv3. Yes, the AI slop is copylefted now. [caelestia-dots/shell](https://github.com/caelestia-dots/shell) -provided architectural inspiration, which the robot then faithfully mangled into this. If you -improve it, the license requires you to share those improvements — a higher standard of -accountability than the author has held themselves to. +To the extent possible under law, all copyright in this repository is +waived and dedicated to the **public domain**. Do whatever you want with it. +The AI certainly didn't care about ownership when it scraped half the +internet to produce this. diff --git a/TODO-appindicators.md b/TODO-appindicators.md deleted file mode 100644 index 73ae76d..0000000 --- a/TODO-appindicators.md +++ /dev/null @@ -1,133 +0,0 @@ -# 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/Flyout.qml b/modules/Flyout.qml index 4b184a8..33a77d9 100644 --- a/modules/Flyout.qml +++ b/modules/Flyout.qml @@ -19,7 +19,7 @@ PanelWindow { anchors.left: true // Flush below bar, centered on hovered item - margins.top: 0 + margins.top: M.Theme.barHeight margins.left: Math.max(0, Math.min( Math.round(M.FlyoutState.itemX - implicitWidth / 2), screen.width - implicitWidth diff --git a/modules/Tray.qml b/modules/Tray.qml index d3bf5aa..ae33beb 100644 --- a/modules/Tray.qml +++ b/modules/Tray.qml @@ -2,7 +2,6 @@ import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Services.SystemTray -import "." as M RowLayout { id: root @@ -26,20 +25,6 @@ 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 @@ -47,26 +32,10 @@ RowLayout { if (mouse.button === Qt.LeftButton) { iconItem.modelData.activate(); } else if (mouse.button === Qt.RightButton) { - if (iconItem.modelData.menu) { - menuLoader.active = true; - } else { - iconItem.modelData.secondaryActivate(); - } + iconItem.modelData.display(root.bar, mouse.x, mouse.y); } } } - - // 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 deleted file mode 100644 index b65d6c7..0000000 --- a/modules/TrayMenu.qml +++ /dev/null @@ -1,235 +0,0 @@ -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 4805e2f..9f995c4 100644 --- a/modules/qmldir +++ b/modules/qmldir @@ -9,7 +9,6 @@ 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