nova-shell/TODO-appindicators.md
2026-04-12 11:35:03 +02:00

4.7 KiB

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 tooltipsSystemTrayItem 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

// 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:

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:

// 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:

// 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.

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.qmlStackView-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.