Files
Huỳnh Thiện Lộc 8ff7ce2cea feat(island): add system tray slot and overflow menu to compact home (#3780)
* feat(island): add system tray slot and overflow menu to compact home

- Add system tray slot and overflow expander menu in Dynamic Island
- Share tray icon path resolution and DBus context menu fallback in TrayMenuManager
- Support edge-anchored context menu positioning across top, bottom, left, and right
- Lock bottom and edge anchoring in DContextMenu to prevent animation jitter

* fix(island): only instantiate tray menus when tray slot is enabled

* feat(island): expand tray item and caret click area to slot height

* fix(island): use Theme.surfaceTextHover and restore DContextMenu height behavior

* refactor(tray): extract shared TrayMenuContent and TrayOverflowGrid, support tray order and IPC in island

* Revert "refactor(tray): extract shared TrayMenuContent and TrayOverflowGrid, support tray order and IPC in island"

This reverts commit 29888f89cd.

* refactor(tray): use shared TrayMenuManager helpers and TrayItemIcon, support tray order and IPC in island

* refactor(tray): extract shared TrayMenuContent and TrayOverflowGrid

* feat(island): support tray item drag and drop reordering

* fix(island): prevent re-entrant tray drag reordering on model destruction

* fix(tray): remove native submenu anchor hydration to prevent popup stutter

* fix(tray): decouple submenu hydrator from anchor window to avoid stutter

* fix(tray): restore anchor window on submenu hydrator and disable on island

* tray: fix bar overflow surface & reuse max visible items on the island
2026-10-14 16:55:26 -04:00

209 lines
7.5 KiB
QML

pragma Singleton
import Quickshell
import Quickshell.Services.SystemTray
import QtQuick
Singleton {
id: root
property var activeTrayMenus: ({})
property var _pendingMenuRequest: null
property var _trayKeyByItem: new Map()
readonly property var envHiddenTrayIds: {
const envValue = Quickshell.env("DMS_HIDE_TRAYIDS") || "";
return envValue ? envValue.split(",").map(id => id.trim().toLowerCase()) : [];
}
function isEnvHidden(itemId) {
if (!itemId || !envHiddenTrayIds.length)
return false;
return envHiddenTrayIds.includes(itemId.toLowerCase());
}
function filterEnvHidden(items) {
if (!items)
return [];
if (!envHiddenTrayIds.length)
return items;
return items.filter(item => !isEnvHidden(item?.id));
}
Connections {
target: SystemTray
function onItemUnregistered(item) {
root._trayKeyByItem.delete(item);
}
}
function getTrayItemKey(item, allItems) {
const id = item?.id || "";
if (!id)
return "";
if (_trayKeyByItem.has(item))
return _trayKeyByItem.get(item);
const pool = allItems || SystemTray.items.values;
const taken = new Set();
for (const live of pool) {
if (live !== item && (live?.id || "") === id && _trayKeyByItem.has(live))
taken.add(_trayKeyByItem.get(live));
}
let key = id;
for (let n = 1; taken.has(key); n++)
key = `${id}::${n}`;
_trayKeyByItem.set(item, key);
return key;
}
function resolveOrderIndex(key, orderMap) {
if (orderMap.has(key))
return orderMap.get(key);
return -1;
}
function sortByPreferredOrder(items, savedOrder) {
if (!items)
return [];
const order = savedOrder || [];
const orderMap = new Map();
order.forEach((key, idx) => orderMap.set(key, idx));
return [...items].sort((a, b) => {
const keyA = getTrayItemKey(a, items);
const keyB = getTrayItemKey(b, items);
const orderA = resolveOrderIndex(keyA, orderMap);
const orderB = resolveOrderIndex(keyB, orderMap);
const posA = orderA >= 0 ? orderA : 10000 + items.indexOf(a);
const posB = orderB >= 0 ? orderB : 10000 + items.indexOf(b);
return posA - posB;
});
}
function dragShiftOffset(index, draggedIndex, dropTargetIndex, shiftAmount) {
if (draggedIndex < 0 || index === draggedIndex || dropTargetIndex < 0)
return 0;
if (draggedIndex < dropTargetIndex && index > draggedIndex && index <= dropTargetIndex)
return -shiftAmount;
if (draggedIndex > dropTargetIndex && index >= dropTargetIndex && index < draggedIndex)
return shiftAmount;
return 0;
}
function reorderItemKeys(allKeys, fromKey, toKey) {
if (!allKeys || !fromKey || !toKey)
return null;
const fullOrder = [...allKeys];
const fromIdx = fullOrder.indexOf(fromKey);
const toIdx = fullOrder.indexOf(toKey);
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx)
return null;
const moved = fullOrder.splice(fromIdx, 1)[0];
fullOrder.splice(toIdx, 0, moved);
return fullOrder;
}
signal openTrayMenuRequested
function requestOpenMenu(itemId, screenName) {
_pendingMenuRequest = {
"itemId": itemId,
"screenName": screenName
};
openTrayMenuRequested();
}
// Every SystemTrayBar instance receives the signal; the claim ensures
// exactly one opens the menu, preferring the requested screen
function claimMenuRequest(instanceScreenName) {
if (!_pendingMenuRequest)
return null;
if (_pendingMenuRequest.screenName && _pendingMenuRequest.screenName !== instanceScreenName)
return null;
const request = _pendingMenuRequest;
_pendingMenuRequest = null;
return request;
}
function findTrayItem(itemId: string): var {
if (!itemId)
return null;
return SystemTray.items.values.find(item => {
const id = item?.id || "";
const title = item?.tooltipTitle || "";
const fullKey = title ? `${id}::${title}` : id;
return fullKey === itemId || id === itemId;
});
}
function registerMenu(screenName, menu) {
if (!screenName || !menu)
return;
const newMenus = Object.assign({}, activeTrayMenus);
newMenus[screenName] = menu;
activeTrayMenus = newMenus;
}
function unregisterMenu(screenName) {
if (!screenName)
return;
const newMenus = Object.assign({}, activeTrayMenus);
delete newMenus[screenName];
activeTrayMenus = newMenus;
}
function closeHoverMenus() {
for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName]
if (!menu || menu.openedByHover !== true) continue
if (typeof menu.close === "function") {
menu.close()
} else if (menu.showMenu !== undefined) {
menu.showMenu = false
}
}
}
function closeAllMenus() {
for (const screenName in activeTrayMenus) {
const menu = activeTrayMenus[screenName];
if (!menu)
continue;
if (typeof menu.close === "function") {
menu.close();
} else if (menu.showMenu !== undefined) {
menu.showMenu = false;
}
}
}
function iconSourceFor(trayItem) {
let icon = trayItem && trayItem.icon;
if (typeof icon === "string" || icon instanceof String) {
if (icon === "")
return "";
if (icon.includes("?path=")) {
const split = icon.split("?path=");
if (split.length !== 2)
return icon;
const name = split[0];
const path = split[1];
let fileName = name.substring(name.lastIndexOf("/") + 1);
if (fileName.startsWith("dropboxstatus"))
fileName = `hicolor/16x16/status/${fileName}`;
return `file://${path}/${fileName}`;
}
if (icon.startsWith("/") && !icon.startsWith("file://"))
return `file://${icon}`;
return icon;
}
return "";
}
function callContextMenuFallback(trayItemId, globalX, globalY) {
const script = ['ITEMS=$(dbus-send --session --print-reply --dest=org.kde.StatusNotifierWatcher /StatusNotifierWatcher org.freedesktop.DBus.Properties.Get string:org.kde.StatusNotifierWatcher string:RegisteredStatusNotifierItems 2>/dev/null)', 'while IFS= read -r line; do', ' line="${line#*\\\"}"', ' line="${line%\\\"*}"', ' [ -z "$line" ] && continue', ' BUS="${line%%/*}"', ' OBJ="/${line#*/}"', ' ID=$(dbus-send --session --print-reply --dest="$BUS" "$OBJ" org.freedesktop.DBus.Properties.Get string:org.kde.StatusNotifierItem string:Id 2>/dev/null | grep -oP "(?<=\\\")(.*?)(?=\\\")" | tail -1)', ' if [ "$ID" = "$1" ]; then', ' dbus-send --session --type=method_call --dest="$BUS" "$OBJ" org.kde.StatusNotifierItem.ContextMenu int32:"$2" int32:"$3"', ' exit 0', ' fi', 'done <<< "$ITEMS"',].join("\n");
Quickshell.execDetached(["bash", "-c", script, "_", trayItemId, String(Math.round(globalX)), String(Math.round(globalY))]);
}
}