fix: stabilize tray item key to prevent position jumps on property changes (#3386)

* fix: keep tray order stable when tooltipTitle changes dynamically

Revert the earlier simplistic key approach: keep getTrayItemKey()
unchanged (id::tooltipTitle) so duplicate-id disambiguation (#1542)
is preserved and persisted id::tooltipTitle entries keep matching.

Instead make persisted-key lookups tolerant of dynamic tooltipTitle:
resolveOrderIndex() in sortByPreferredOrder() does exact match first,
falling back to prefix matching an id::* entry only when the id has a
single instance. findSavedTrayKey()/isTrayIdHidden() replace direct
SessionData.isHiddenTrayId() calls and showTrayId() now receives the
actual saved key.

Prefix fallback is gated by trayItemCountForId() to single-instance
ids only, so multiple items sharing an id always match exactly and
hiding/ordering one never affects the others.

* fix: bail on ambiguous saved tray keys in fallback

* tray: use occurrence index as stable key, add one-time session migration

* tray: pin stable key per item instance so it never flips when siblings come and go

* tray: assign unique per-id sequential keys to avoid collision after sibling exit

* tray: reuse free key slots so a restarted singleton keeps its plain id

(cherry picked from commit 60342b7416)
This commit is contained in:
LemonMon
2026-10-07 13:01:06 -04:00
committed by bbedward
parent 8b7c9085ca
commit fb002d1977
3 changed files with 64 additions and 11 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("SessionData")
readonly property int sessionConfigVersion: 4
readonly property int sessionConfigVersion: 5
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -83,5 +83,29 @@ function migrateToVersion(obj, targetVersion, settingsData) {
session.configVersion = 4;
}
if (currentVersion < 5) {
console.info("SessionData: Migrating session to version 5");
console.info("SessionData: Tray keys now use a stable id (or id::instance) instead of id::tooltipTitle");
function stripTrayKeys(list) {
if (!list || list.constructor !== Array) return list;
var out = [];
for (var i = 0; i < list.length; i++) {
var raw = list[i];
if (typeof raw !== "string") continue;
var key = raw.includes("::") ? raw.split("::")[0] : raw;
var hasDup = false;
for (var j = 0; j < out.length; j++) {
if (out[j] === key) { hasDup = true; break; }
}
if (key && !hasDup) out.push(key);
}
return out;
}
if (session.hiddenTrayIds !== undefined) session.hiddenTrayIds = stripTrayKeys(session.hiddenTrayIds);
if (session.trayItemOrder !== undefined) session.trayItemOrder = stripTrayKeys(session.trayItemOrder);
session.configVersion = 5;
}
return session;
}
@@ -40,13 +40,33 @@ BasePill {
return !hiddenTrayIds.includes(itemId.toLowerCase());
});
}
property var _trayKeyByItem: new Map()
function getTrayItemKey(item) {
const id = item?.id || "";
const tooltipTitle = item?.tooltipTitle || "";
if (!tooltipTitle || tooltipTitle === id) {
return id;
if (!id)
return "";
if (root._trayKeyByItem.has(item))
return root._trayKeyByItem.get(item);
const taken = new Set();
for (const live of root.allTrayItems) {
if (live !== item && (live?.id || "") === id && root._trayKeyByItem.has(live))
taken.add(root._trayKeyByItem.get(live));
}
return `${id}::${tooltipTitle}`;
let key = id;
for (let n = 1; taken.has(key); n++)
key = `${id}::${n}`;
root._trayKeyByItem.set(item, key);
return key;
}
function resolveOrderIndex(key, orderMap) {
if (orderMap.has(key))
return orderMap.get(key);
return -1;
}
function isTrayIdHidden(key) {
return SessionData.isHiddenTrayId(key);
}
function trayIconSourceFor(trayItem) {
@@ -150,6 +170,13 @@ BasePill {
}
}
Connections {
target: SystemTray
function onItemUnregistered(item) {
root._trayKeyByItem.delete(item);
}
}
function sortByPreferredOrder(items, trigger) {
void trigger;
const savedOrder = SessionData.trayItemOrder || [];
@@ -159,15 +186,17 @@ BasePill {
return [...items].sort((a, b) => {
const keyA = getTrayItemKey(a);
const keyB = getTrayItemKey(b);
const orderA = orderMap.has(keyA) ? orderMap.get(keyA) : 10000 + items.indexOf(a);
const orderB = orderMap.has(keyB) ? orderMap.get(keyB) : 10000 + items.indexOf(b);
return orderA - orderB;
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;
});
}
readonly property var allSortedTrayItems: sortByPreferredOrder(allTrayItems, _trayOrderTrigger)
readonly property var allSortedTrayItemKeys: allSortedTrayItems.map(item => getTrayItemKey(item))
readonly property var visibleSortedTrayItems: allSortedTrayItems.filter(item => !SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
readonly property var visibleSortedTrayItems: allSortedTrayItems.filter(item => !root.isTrayIdHidden(root.getTrayItemKey(item)))
readonly property int automaticVisibleItemLimit: {
if (!root.useAutomaticOverflow)
return root.visibleSortedTrayItems.length;
@@ -190,7 +219,7 @@ BasePill {
item: item
}))
readonly property var autoOverflowBarItems: visibleSortedTrayItems.slice(automaticVisibleItemLimit)
readonly property var manualHiddenBarItems: allSortedTrayItems.filter(item => SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
readonly property var manualHiddenBarItems: allSortedTrayItems.filter(item => root.isTrayIdHidden(root.getTrayItemKey(item)))
readonly property var hiddenBarItemKeys: manualHiddenBarItems.concat(autoOverflowBarItems).map(item => root.getTrayItemKey(item))
readonly property var hiddenBarItems: allSortedTrayItems.filter(item => hiddenBarItemKeys.indexOf(root.getTrayItemKey(item)) !== -1)
readonly property string trayIconTintMode: {
@@ -298,7 +327,7 @@ BasePill {
}
function isManualHiddenTrayItem(item) {
return SessionData.isHiddenTrayId(getTrayItemKey(item));
return root.isTrayIdHidden(root.getTrayItemKey(item));
}
function isAutoOverflowTrayItem(item) {