quickshell: per-icon tray menu cache, kill the switch flash

One shared QsMenuOpener meant tray-to-tray switching reassigned the
handle and reloaded over DBus — the model went briefly empty, and no
sizing gate downstream could hide that. Each tray icon now owns its
opener, assigned when the icon appears, so every menu (Steam's slow
one included) is fetched once at startup and held. Switching swaps one
populated model for another in a single binding pass; contextMenu just
repoints activeOpener. Hover prefetch deleted — nothing left to
prefetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-07-28 15:24:55 +01:00
parent d866cae4f7
commit 5c71cc3a1f

View file

@ -1847,19 +1847,22 @@ in
color: Theme.base05 color: Theme.base05
} }
// The icon's own menu cache: assigned from the
// moment the icon exists, so the DBus fetch
// (slow for Steam) happens at startup, not
// mid-animation. contextMenu just points at
// this switching tray icons never reloads.
QsMenuOpener {
id: itemOpener
menu: modelData.menu
}
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
acceptedButtons: Qt.NoButton acceptedButtons: Qt.NoButton
onEntered: { onEntered: {
if (!modelData.hasMenu) return; if (!modelData.hasMenu) return;
// Prefetch: you cannot click without
// hovering first, so start the DBus
// round-trip now and the menu is
// usually loaded before the click.
if (!bar.activeDropdown && menuOpener.menu !== modelData.menu) {
menuOpener.menu = modelData.menu;
}
if (!bar.activeDropdown) return; if (!bar.activeDropdown) return;
if (bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData) return; if (bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData) return;
let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2); let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2);
@ -1867,9 +1870,9 @@ in
// Same dropdown, just switch content // Same dropdown, just switch content
contextMenu.dropdownY = pos.y; contextMenu.dropdownY = pos.y;
contextMenu.trayItem = modelData; contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu; contextMenu.activeOpener = itemOpener;
} else { } else {
contextMenu.openFor(modelData, pos.y); contextMenu.openFor(modelData, pos.y, itemOpener);
} }
} }
} }
@ -1884,7 +1887,7 @@ in
contextMenu.animateClose(); contextMenu.animateClose();
} else { } else {
let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2); let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2);
contextMenu.openFor(modelData, pos.y); contextMenu.openFor(modelData, pos.y, itemOpener);
} }
} }
} }
@ -2130,8 +2133,8 @@ in
// available the instant the data lands, so the height and // available the instant the data lands, so the height and
// its content update in the same binding pass. // its content update in the same binding pass.
readonly property real menuContentHeight: { readonly property real menuContentHeight: {
if (!menuOpener.children) return 0; if (!menuModel) return 0;
const items = menuOpener.children.values; const items = menuModel.values;
let h = 0; let h = 0;
for (let i = 0; i < items.length; i++) for (let i = 0; i < items.length; i++)
h += items[i].isSeparator ? menuSepHeight : menuRowHeight; h += items[i].isSeparator ? menuSepHeight : menuRowHeight;
@ -2148,26 +2151,23 @@ in
fullHeight: (menuContentHeight > 0 ? menuContentHeight : lastMenuHeight) fullHeight: (menuContentHeight > 0 ? menuContentHeight : lastMenuHeight)
+ 2 * Theme.cardPad + 2 * Theme.panelGap + 2 * Theme.cardPad + 2 * Theme.panelGap
// Deliberately NOT cleared on close. Nulling the menu threw // Which tray item's opener feeds the menu. Every tray icon
// away the loaded items, and the only thing that refills // owns an always-assigned QsMenuOpener (see the tray
// them is a hover-enter which never fires if the cursor // Repeater), fetched when the icon appears so switching
// is still sitting on the icon you just clicked. Reopening // traytray swaps one POPULATED model for another in a
// then re-fetched over DBus and the panel grew to its empty // single binding pass. The shared opener this replaces
// height first, then jumped once the items landed. Keeping // reloaded over DBus on every reassignment, and no sizing
// the handle assigned makes reopening the same item free; // gate downstream could hide that the model itself went
// openFor() reassigns it when you pick a different one. // briefly empty (that gap was the flash).
property var activeOpener: null
readonly property var menuModel: activeOpener ? activeOpener.children : null
QsMenuOpener { // Opens immediately the item's opener has been loaded
id: menuOpener // since its icon appeared, so there is nothing to wait on.
} function openFor(item, y, opener) {
// Opens immediately never wait on the menu. Hovering the
// icon has already started the DBus fetch (see the tray
// MouseArea), so the size is normally settled by click.
function openFor(item, y) {
dropdownY = y; dropdownY = y;
trayItem = item; trayItem = item;
menuOpener.menu = item.menu; activeOpener = opener;
bar.toggleDropdown(contextMenu, null); bar.toggleDropdown(contextMenu, null);
} }
@ -2178,7 +2178,7 @@ in
cardSpacing: 0 cardSpacing: 0
Repeater { Repeater {
model: menuOpener.children model: contextMenu.menuModel
Rectangle { Rectangle {
required property var modelData required property var modelData