From e71a816e9032f040225e086c7256f73e8080ae45 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 11 Oct 2026 23:03:30 +0900 Subject: [PATCH] Cache workspace layouts instead of running a script on every switch (#15028) * Cache workspace layouts instead of running a script on every switch Co-Authored-By: Claude Opus 5.5 * Test the workspace layout cache by running its logic, not matching its source Co-Authored-By: Claude Opus 5.5 * Keep the fake layout read running while its exit handler runs Addresses Codex review: clearing running first let an undeferred, broken retry pass the cache tests. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- shell/plugins/bar/widgets/Workspaces.qml | 45 ++++++++++-- .../hyprland-floating-workspaces-test.sh | 69 +++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/shell/plugins/bar/widgets/Workspaces.qml b/shell/plugins/bar/widgets/Workspaces.qml index 4d808baf..3657683e 100644 --- a/shell/plugins/bar/widgets/Workspaces.qml +++ b/shell/plugins/bar/widgets/Workspaces.qml @@ -48,14 +48,43 @@ BarWidget { return "\uDB85\uDCFB" } - function refresh() { + // A layout only changes through the layout commands, which ask for a refresh, + // or a config reload, so each workspace's mode is read once and cached rather + // than running the script on every workspace switch. + property var modeCache: ({}) + property int modeGeneration: 0 + property int modeProcWorkspace: -1 + property int modeProcGeneration: 0 + + function focusedWorkspaceId() { + return Hyprland.focusedWorkspace ? Hyprland.focusedWorkspace.id : -1 + } + + function showFocusedMode() { + var mode = root.modeCache[root.focusedWorkspaceId()] + if (mode !== undefined) root.focusedMode = mode + else readMode() + } + + function readMode() { if (modeProc.running) { root.modeStale = true return } + var id = root.focusedWorkspaceId() + if (id === -1) return + root.modeProcWorkspace = id + root.modeProcGeneration = root.modeGeneration + modeProc.command = ["omarchy-hyprland-workspace-layout-current", String(id)] modeProc.running = true } + function refresh() { + root.modeCache = ({}) + root.modeGeneration++ + readMode() + } + function cycleLayout() { if (root.bar) root.bar.run("omarchy-hyprland-workspace-layout-toggle") } @@ -64,22 +93,28 @@ BarWidget { Connections { target: Hyprland - function onFocusedWorkspaceChanged() { root.refresh() } + function onFocusedWorkspaceChanged() { root.showFocusedMode() } + function onRawEvent(event) { + if (event && String(event.name) === "configreloaded") root.refresh() + } } Process { id: modeProc - command: ["omarchy-hyprland-workspace-layout-current"] stdout: StdioCollector { onStreamFinished: { var mode = text.trim() - if (mode) root.focusedMode = mode + // A read that started before a refresh may predate the change. + if (!mode || root.modeProcGeneration !== root.modeGeneration) return + root.modeCache[root.modeProcWorkspace] = mode + if (root.modeProcWorkspace === root.focusedWorkspaceId()) root.focusedMode = mode } } onExited: { if (!root.modeStale) return root.modeStale = false - root.refresh() + // Deferred: the process still reports running while it signals exit. + Qt.callLater(root.showFocusedMode) } } diff --git a/test/shell.d/hyprland-floating-workspaces-test.sh b/test/shell.d/hyprland-floating-workspaces-test.sh index 67f6d3ce..edef4eaa 100755 --- a/test/shell.d/hyprland-floating-workspaces-test.sh +++ b/test/shell.d/hyprland-floating-workspaces-test.sh @@ -455,3 +455,72 @@ assert(bar and bar.bar_color == "rgb(223344)", "titlebars missed the theme with fail "titlebars follow the theme when XDG_STATE_HOME points elsewhere" fi pass "titlebars follow the theme when XDG_STATE_HOME points elsewhere" + +run_node_test <<'JS' +const source = require('fs').readFileSync(path.join(root, 'shell/plugins/bar/widgets/Workspaces.qml'), 'utf8') +assert(source.includes('function onFocusedWorkspaceChanged() { root.showFocusedMode() }'), 'a workspace switch shows the cached layout instead of running the script') +assert(source.includes('String(event.name) === "configreloaded") root.refresh()'), 'a config reload drops every cached layout') + +// Run the widget's own cache logic against a fake Hyprland and process. +function body(pattern) { + const match = source.match(pattern) + if (!match) fail('Workspaces.qml cache logic can be extracted', String(pattern)) + return match[1] +} +// QML resolves bare calls like readMode() on the widget, so it is passed in too. +const args = ['root', 'Hyprland', 'modeProc', 'Qt', 'readMode'] +const fn = name => new Function(...args, body(new RegExp(`\\n function ${name}\\(\\) \\{([\\s\\S]*?)\\n \\}`))) +const streamFinished = new Function(...args, 'text', body(/onStreamFinished: \{([\s\S]*?)\n \}/)) +const exited = new Function(...args, body(/onExited: \{([\s\S]*?)\n \}/)) + +const hyprland = { focusedWorkspace: { id: 1 } } +const proc = { running: false, command: null, runs: [] } +const deferred = [] +const qt = { callLater: callback => deferred.push(callback) } +const widget = { focusedMode: 'dwindle', modeStale: false, modeCache: {}, modeGeneration: 0, modeProcWorkspace: -1, modeProcGeneration: 0 } +for (const name of ['focusedWorkspaceId', 'showFocusedMode', 'readMode', 'refresh']) { + const call = fn(name) + widget[name] = () => call(widget, hyprland, proc, qt, widget.readMode) +} +const start = () => { if (proc.running) proc.runs.push(proc.command[1]) } +// Quickshell still reports the process running while it signals exit, so a +// retry has to be deferred until after the handler returns. +const finish = output => { + streamFinished(widget, hyprland, proc, qt, widget.readMode, output) + exited(widget, hyprland, proc, qt, widget.readMode) + proc.running = false + while (deferred.length) deferred.shift()() +} +const focus = id => { hyprland.focusedWorkspace = { id }; widget.showFocusedMode(); start() } + +widget.refresh(); start() +finish('scrolling\n') +assertEqual(widget.focusedMode, 'scrolling', 'the first read shows the focused layout') +focus(2); finish('floating\n') +focus(1) +assertEqual(widget.focusedMode, 'scrolling', 'returning to a read workspace shows its cached layout') +assertDeepEqual(proc.runs, ['1', '2'], 'a cached workspace switch runs no script') + +widget.refresh(); start() +assert(proc.running && proc.runs.length === 3, 'a refresh reads the focused layout again') +assertDeepEqual(widget.modeCache, {}, 'a refresh drops every cached layout') +widget.refresh() +assert(widget.modeStale, 'a refresh during a read marks it stale') +finish('dwindle\n') +assert(widget.modeCache[1] === undefined, 'a read that predates a refresh is not cached') +start() +assertDeepEqual(proc.runs, ['1', '2', '1', '1'], 'a stale read is retried once it exits') +finish('floating\n') +assertEqual(widget.focusedMode, 'floating', 'the retried read shows the current layout') + +widget.refresh(); start() +focus(2) +assert(widget.modeStale, 'switching to an uncached workspace during a read marks it pending') +finish('scrolling\n') +assertEqual(widget.modeCache[1], 'scrolling', 'the in-flight read still caches its own workspace') +assertEqual(widget.focusedMode, 'floating', 'a read for a workspace no longer focused leaves the shown layout alone') +start() +assertEqual(proc.runs[proc.runs.length - 1], '2', 'the pending read follows the newly focused workspace') +finish('dwindle\n') +assertEqual(widget.focusedMode, 'dwindle', 'the newly focused workspace shows its own layout') +JS