Files
DankMaterialShell/quickshell/Common/TrayMenuManager.qml
T
2026-10-27 14:03:12 +00:00

250 lines
8.9 KiB
QML

pragma Singleton
import Quickshell
import Quickshell.Services.SystemTray
import QtQuick
import qs.Common
import "../Services/IconThemeIndex.js" as IconIndex
Singleton {
id: root
property var activeTrayMenus: ({})
property var _pendingMenuRequest: null
property var _trayKeyByItem: new Map()
property var _iconFallbacks: ({})
property var _iconFallbackWaiters: ({})
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 iconRequest(icon) {
if (typeof icon !== "string" || !icon.includes("?path="))
return null;
const split = icon.split("?path=");
if (split.length !== 2)
return null;
const name = split[0].substring(split[0].lastIndexOf("/") + 1);
return {
name: name,
dir: split[1]
};
}
function iconSourceFor(trayItem) {
const icon = trayItem && trayItem.icon;
if (typeof icon !== "string" || icon === "")
return "";
const request = iconRequest(icon);
if (request)
return `file://${request.dir}/${request.name}`;
if (icon.startsWith("/"))
return `file://${icon}`;
return icon;
}
function resolveIconFallback(icon, callback) {
const request = iconRequest(icon);
if (!request || !/^[\w.+-]+$/.test(request.name)) {
callback("");
return;
}
const cached = _iconFallbacks[icon];
if (cached) {
callback(cached);
return;
}
const waiters = _iconFallbackWaiters[icon];
if (waiters) {
waiters.push(callback);
return;
}
_iconFallbackWaiters[icon] = [callback];
Proc.runCommand(null, ["find", "-L", request.dir, "-maxdepth", "4", "-type", "f", "-name", `${request.name}.*`], out => {
const index = IconIndex.create();
for (const path of (out || "").split("\n")) {
if (path)
IconIndex.add(index, path, [request.dir], [request.dir]);
}
const found = index.paths[request.name];
const url = found ? Paths.toFileUrl(found) : Paths.themedIconPath(request.name);
if (url)
_iconFallbacks[icon] = url;
const pending = _iconFallbackWaiters[icon] || [];
delete _iconFallbackWaiters[icon];
for (const waiter of pending)
waiter(url);
}, 0);
}
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))]);
}
}