From d2ad778e6fe3e6be030f1b5c513dcc0fdca2d91b Mon Sep 17 00:00:00 2001 From: hecate cantus Date: Tue, 27 Oct 2026 10:09:28 -0700 Subject: [PATCH] Fix/3958 workspace indicator icon perf (#3961) port 2.0 (cherry picked from commit b2e1817aad225764790872e36e5a643020564085) --- .../DBar/Widgets/WorkspaceSwitcher.qml | 10 ++-- quickshell/Services/IconThemeIndex.js | 10 +++- quickshell/Widgets/WorkspaceSwitcherModel.qml | 49 +++++++++++-------- quickshell/tests/icon-theme-index.test.mjs | 13 +++++ 4 files changed, 57 insertions(+), 25 deletions(-) diff --git a/quickshell/Modules/DBar/Widgets/WorkspaceSwitcher.qml b/quickshell/Modules/DBar/Widgets/WorkspaceSwitcher.qml index 661ea188a..dcc19cccd 100644 --- a/quickshell/Modules/DBar/Widgets/WorkspaceSwitcher.qml +++ b/quickshell/Modules/DBar/Widgets/WorkspaceSwitcher.qml @@ -83,7 +83,7 @@ BasePill { function getWorkspaceIcons(ws) { _desktopEntriesUpdateTrigger; - if (!root.opt("showWorkspaceApps") || !ws || !root.useNativeWorkspaces || ws.placeholder) { + if (!root.opt("showWorkspaceApps") || !ws || !root.useNativeWorkspaces || ws.placeholder || !IconThemeService.ready) { return []; } @@ -675,8 +675,8 @@ BasePill { const didReorder = wasDragging && root.dragTargetIndex >= 0 && root.dragTargetIndex !== root.dragSourceIndex; if (didReorder) { - const sourceWs = root.workspaceList[root.dragSourceIndex]; - const targetWs = root.workspaceList[root.dragTargetIndex]; + const sourceWs = root.recordOf(root.workspaceList[root.dragSourceIndex]); + const targetWs = root.recordOf(root.workspaceList[root.dragTargetIndex]); if (sourceWs && targetWs && sourceWs.id !== undefined && targetWs.idx !== undefined) { root.suppressShiftAnimation = true; @@ -983,6 +983,7 @@ BasePill { Repeater { model: ScriptModel { values: loadedIcons.slice(0, root.opt("maxWorkspaceIcons")) + comparisonMode: ObjectComparison.Structure } delegate: Item { width: root.appIconSize @@ -1141,6 +1142,7 @@ BasePill { Repeater { model: ScriptModel { values: loadedIcons.slice(0, root.opt("maxWorkspaceIcons")) + comparisonMode: ObjectComparison.Structure } delegate: Item { width: root.appIconSize @@ -1281,6 +1283,8 @@ BasePill { } readonly property var rootCurrentWorkspace: root.currentWorkspace onRootCurrentWorkspaceChanged: updateAllData() + readonly property bool iconIndexReady: IconThemeService.ready + onIconIndexReadyChanged: updateAllData() Connections { target: SettingsData function onBarConfigsChanged() { diff --git a/quickshell/Services/IconThemeIndex.js b/quickshell/Services/IconThemeIndex.js index 7571bff47..667191a62 100644 --- a/quickshell/Services/IconThemeIndex.js +++ b/quickshell/Services/IconThemeIndex.js @@ -40,6 +40,14 @@ function chainIndex(path, searchDirs, looseDirs) { return searchDirs.length + looseDirs.length; } +const LARGEST_PREFERRED_RASTER = 256; + +function rasterScore(size) { + if (size <= LARGEST_PREFERRED_RASTER) + return LARGEST_PREFERRED_RASTER + size; + return Math.max(0, LARGEST_PREFERRED_RASTER - size / 8); +} + function score(path, searchDirs, looseDirs) { let s = 0; if (path.includes("/apps/")) @@ -59,7 +67,7 @@ function score(path, searchDirs, looseDirs) { } else { const m = path.match(/\/(\d+)(?:x\d+)?(?:@\d+x)?\//); if (m) - s += Math.min(parseInt(m[1]), 999); + s += rasterScore(parseInt(m[1])); } return s; } diff --git a/quickshell/Widgets/WorkspaceSwitcherModel.qml b/quickshell/Widgets/WorkspaceSwitcherModel.qml index 748f0e25f..e67e95803 100644 --- a/quickshell/Widgets/WorkspaceSwitcherModel.qml +++ b/quickshell/Widgets/WorkspaceSwitcherModel.qml @@ -61,25 +61,24 @@ QtObject { "minCount": showPadding ? paddingCount : 0, "showSpecial": showSpecial }); - if (CompositorService.ephemeralWorkspaces) - return hyprlandSlotList(baseList); - if (!showPadding || CompositorService.supportsPersistentWorkspaces || (useAqueous && baseList.length === 0)) - return baseList; - return padWorkspaces(baseList); + const slots = slotList(baseList); + if (CompositorService.ephemeralWorkspaces || !showPadding || CompositorService.supportsPersistentWorkspaces || (useAqueous && baseList.length === 0)) + return slots; + return padWorkspaces(slots); } - readonly property var realWorkspaces: workspaceList.filter(ws => ws && !recordOf(ws).placeholder) + readonly property var realWorkspaces: workspaceList.map(ws => recordOf(ws)).filter(ws => ws && !ws.placeholder) readonly property bool hasWorkspaces: realWorkspaces.length > 0 readonly property bool available: useNativeWorkspaces || (useExtWorkspace && hasWorkspaces) onCompositorNameChanged: { _placeholderPool = []; - _hyprSlotPool = {}; + _slotPool = {}; } // Hyprland creates/destroys workspaces on empty enter/leave; slots keyed by id keep delegate identity so pills animate instead of popping - property var _hyprSlotPool: ({}) - readonly property Component _hyprSlotComponent: Component { + property var _slotPool: ({}) + readonly property Component _slotComponent: Component { QtObject { property var ws: null } @@ -91,19 +90,27 @@ QtObject { return entry.ws; } - function _hyprSlot(key, ws) { - let slot = _hyprSlotPool[key]; - if (!slot) { - slot = _hyprSlotComponent.createObject(root); - _hyprSlotPool[key] = slot; + function slotList(raw) { + const live = {}; + const slots = raw.map(ws => { + const key = ws.id !== null && ws.id !== undefined && !ws.special ? ws.id : (ws.special ? "special:" : "name:") + (ws.name ?? ""); + let slot = _slotPool[key]; + if (!slot) { + slot = _slotComponent.createObject(root); + _slotPool[key] = slot; + } + if (slot.ws !== ws) + slot.ws = ws; + live[key] = true; + return slot; + }); + for (const key in _slotPool) { + if (live[key]) + continue; + _slotPool[key].destroy(); + delete _slotPool[key]; } - if (slot.ws !== ws) - slot.ws = ws; - return slot; - } - - function hyprlandSlotList(raw) { - return raw.map(ws => _hyprSlot(ws.id > 0 ? ws.id : (ws.special ? "special:" : "name:") + (ws.name ?? ""), ws)); + return slots; } // Stable placeholder instances so ScriptModel reuses padding delegates instead of recreating them on workspace churn diff --git a/quickshell/tests/icon-theme-index.test.mjs b/quickshell/tests/icon-theme-index.test.mjs index e807f4b58..58c62af0d 100644 --- a/quickshell/tests/icon-theme-index.test.mjs +++ b/quickshell/tests/icon-theme-index.test.mjs @@ -32,6 +32,19 @@ test("index keeps the existing category, root, format, size and tie priorities", assert.equal(build(ties).icon, ties[0]); }); +test("index prefers rasters up to 256px over larger ones, and the smallest of those otherwise", () => { + const cases = [ + ["/system/hicolor/apps/1024x1024/icon.png", "/system/hicolor/apps/256x256/icon.png"], + ["/system/hicolor/apps/512x512/icon.png", "/system/hicolor/apps/128x128/icon.png"], + ["/system/hicolor/apps/1024x1024/icon.png", "/system/hicolor/apps/512x512/icon.png"], + ["/system/hicolor/apps/256x256/icon.png", "/system/hicolor/apps/scalable/icon.png"] + ]; + for (const [loser, winner] of cases) { + assert.equal(build([loser, winner]).icon, winner); + assert.equal(build([winner, loser]).icon, winner); + } +}); + test("index bounds retained paths by resolvable names and handles object property names", () => { const paths = ["/user/theme/apps/icon.svg", "/user/theme/apps/16/icon.png", "/system/pixmaps/loose.xpm", "/user/theme/apps/__proto__.svg", "/user/theme/apps/constructor.png", "/user/theme/apps/not a name.svg"]; const result = build(paths);