From 4c77bc2f614954cb0c67a894818fae48c4ab7b03 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Fri, 21 Aug 2026 02:16:29 -0400 Subject: [PATCH] Decide whether the other screens join in on workspaces GNOME's Multitasking panel asked one workspace question worth reproducing, and it is not which workspace goes on which screen. It is whether the second screen participates at all: workspaces on the primary display only, or each screen with its own. Ten rows of per-workspace assignment would be more powerful and worse. Off is Hyprland's own behaviour and emits nothing. On pins workspaces 1 to 10 -- however many ALT+1..ALT+0 actually reach, read from keybinds.lua rather than written down twice -- to whichever output is recorded as primary. With no primary recorded, nothing is pinned: guessing one would move every workspace onto whichever output happened to sort first, and this machine is in exactly that state. Applying is a reload, which is the part that shaped the design. Hyprland reads workspace rules at config time and will not remove one afterwards -- a rule written with an empty monitor keeps its old binding, which was checked rather than assumed. Only a reload clears them, so the config is the only honest source and the page cannot pretend a change has landed before one happens. Hence a service that reads `hyprctl workspacerules` back rather than inferring success from having written the preference, and a Reload row that exists only while the two disagree. Verified end to end against the live compositor and put back: off emits nothing, on emits ten rules naming the primary, and turning it off clears them. The settings file came back byte-identical. Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1 --- README.md | 2 +- config/dot/hypr/monitors.lua | 36 +++++ .../quickshell/config/PreferenceSchema.qml | 13 ++ .../modules/settings/DisplaysPage.qml | 45 +++++++ config/dot/quickshell/services/Workspaces.qml | 120 +++++++++++++++++ .../share/vicinae/scripts/settings-displays | 2 +- docs/settings.md | 3 +- tests/hypr/workspace-rules-contract | 125 ++++++++++++++++++ 8 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 config/dot/quickshell/services/Workspaces.qml create mode 100755 tests/hypr/workspace-rules-contract diff --git a/README.md b/README.md index 2c01c57..b11c9e9 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -128 of them, under `tests/`. Run the lot, or a subset by pattern: +129 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/hypr/monitors.lua b/config/dot/hypr/monitors.lua index 5b308e6..28f1970 100644 --- a/config/dot/hypr/monitors.lua +++ b/config/dot/hypr/monitors.lua @@ -172,4 +172,40 @@ hl.monitor({ scale = "auto", }) +-- ── Workspaces on the primary display only ────────────────────────────────── +-- +-- GNOME offered one workspace choice worth reproducing: whether the other +-- screens join in. Off, every monitor has its own workspaces and switching +-- affects whichever one has focus -- Hyprland's own behaviour, so it needs no +-- rules at all. On, workspaces 1-10 are pinned to the primary display and a +-- second screen keeps a workspace of its own that stays put. +-- +-- Ten because that is how many the keybinds reach: ALT+1 through ALT+0 in +-- keybinds.lua. Binding more would pin workspaces nothing can navigate to, and +-- binding fewer would leave the last few behaving differently from the rest for +-- no reason a person could see. +-- +-- The rules are emitted here rather than written live because Hyprland reads +-- them at config time and offers no way to remove one afterwards: writing an +-- empty monitor leaves the previous binding in place. So the config is the only +-- honest source, and applying a change is a reload. +if prefs.get("workspacesOnPrimaryOnly", false) == true then + local primary = nil + for output, entry in pairs(displays) do + if type(entry) == "table" and entry.primary == true + and type(output) == "string" and output:match("^[%w_.-]+$") ~= nil then + primary = output + break + end + end + + -- Without a primary there is nothing to pin to, and guessing one would move + -- every workspace onto whichever screen happened to sort first. + if primary ~= nil then + for i = 1, 10 do + hl.workspace_rule({ workspace = tostring(i), monitor = primary }) + end + end +end + return true diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 618f5dc..de3ef43 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -179,6 +179,19 @@ Singleton { // These three are written to the compositor and verified by read-back. // See services/SystemSettings.qml for why the exit code cannot be // trusted for either hyprctl keyword or hyprctl eval. + // GNOME's Multitasking panel had exactly this choice, and it is the one + // worth reproducing: not which workspace goes on which screen, but + // whether the second screen participates in workspaces at all. + // + // No `hypr` block, because this is not an option. It becomes workspace + // rules in monitors.lua, and Hyprland reads those at config time and + // will not let one be removed afterwards -- so applying a change is a + // reload rather than a write, which is what Workspaces.qml owns. + { + key: "workspacesOnPrimaryOnly", type: "bool", def: false, group: "display", + label: "Workspaces on the primary display only", + detail: "Other screens keep one workspace of their own rather than switching along with it" + }, { key: "autoHdr", type: "bool", def: true, group: "display", label: "Game-aware HDR", diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 7164491..f320710 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -321,6 +321,51 @@ SettingsPage { } } + // Only with something to spread across. On one screen the choice has no + // meaning, the same way the Touchpad card stays hidden without a touchpad. + SettingsCard { + visible: Displays.monitors.length >= 2 + title: "Workspaces" + subtitle: "GNOME asked this too. Off, every screen has its own workspaces and switching moves the one you are looking at; on, workspaces belong to the primary display and the others keep a screen of their own." + + SegmentRow { + label: "Where workspaces live" + detail: Workspaces.applied + ? (Workspaces.primaryOnly + ? "Workspaces 1 to 10 are on the primary display." + : "Each display has its own workspaces.") + : "Chosen, but not in effect yet — the compositor has to reload." + options: [ + { value: false, label: "All displays" }, + { value: true, label: "Primary only" } + ] + value: Workspaces.primaryOnly + enabled: !Workspaces.reloading + divider: !Workspaces.applied + onSelected: value => Workspaces.choose(value === true) + } + + // Appears only when the compositor and the preference disagree, which + // is also how it disappears: applying makes its own reason to exist go + // away. A reload is a whole-session event, so it is asked for rather + // than done quietly the moment the switch moves. + ActionRow { + visible: !Workspaces.applied + label: Workspaces.reloading ? "Reloading…" : "Reload to apply" + detail: "Re-reads the compositor's configuration. Windows and workspaces stay where they are." + action: "Reload" + enabled: !Workspaces.reloading + divider: false + onTriggered: Workspaces.apply() + } + } + + SettingsCard { + visible: Workspaces.lastError !== "" + title: "Workspace problem" + subtitle: Workspaces.lastError + } + SettingsCard { title: "Gaming display policy" subtitle: "Applied immediately and restored when the session starts." diff --git a/config/dot/quickshell/services/Workspaces.qml b/config/dot/quickshell/services/Workspaces.qml new file mode 100644 index 0000000..f769863 --- /dev/null +++ b/config/dot/quickshell/services/Workspaces.qml @@ -0,0 +1,120 @@ +pragma Singleton + +// ───────────────────────────────────────────────────────────────────────────── +// Whether the other screens join in on workspaces. +// +// The preference is ordinary; applying it is not. Workspace rules are read by +// Hyprland at config time and cannot be taken back at runtime -- writing a rule +// with an empty monitor leaves the old binding in place, which was checked +// rather than assumed. Only `hyprctl reload` clears them, and it re-runs the +// whole config, so monitors.lua re-emits exactly the set the preference asks +// for. +// +// That makes this service two things: the reload, and an honest answer to "has +// it actually taken effect yet". The second matters more. A page that offers a +// switch and silently does nothing until the next login is the failure this +// repository keeps refusing to ship, so `applied` is read back from the +// compositor rather than inferred from the preference having been written. +// ───────────────────────────────────────────────────────────────────────────── + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.config + +Singleton { + id: root + + // The stored intent. + readonly property bool primaryOnly: DesktopPreferences.get("workspacesOnPrimaryOnly") === true + + // What the compositor is actually running, as reported by hyprctl. + property var rules: [] + property bool reloading: false + property string lastError: "" + + // The keybinds reach ALT+1..ALT+0, and monitors.lua pins that many. + readonly property int boundWorkspaces: 10 + + // Every monitor named by a rule. When workspaces are pinned there is + // exactly one, and it is the primary. + readonly property var pinnedMonitors: { + const names = []; + for (const rule of root.rules) { + const monitor = rule?.monitor; + if (typeof monitor === "string" && monitor !== "" && !names.includes(monitor)) + names.push(monitor); + } + return names; + } + + // Has the compositor caught up with the preference? + // + // Pinned means every bound workspace carries a rule naming one monitor; + // unpinned means no rules at all. Anything in between is a config that was + // changed without a reload, which is precisely the state worth reporting. + readonly property bool applied: root.primaryOnly + ? (root.rules.length >= root.boundWorkspaces && root.pinnedMonitors.length === 1) + : root.rules.length === 0 + + Process { + id: query + command: ["hyprctl", "-j", "workspacerules"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.rules = Array.isArray(parsed) ? parsed : []; + } catch (error) { + // An unparseable answer is not an empty rule set. Claiming + // it was would report "not pinned" for a desktop that is. + root.lastError = "Could not read the compositor's workspace rules."; + } + } + } + } + + Process { + id: reloadRun + command: ["hyprctl", "reload"] + onExited: (exitCode, exitStatus) => { + root.reloading = false; + if (exitCode !== 0) { + root.lastError = "The compositor did not reload."; + return; + } + root.lastError = ""; + // The preference file is written on a timer and the reload has to + // re-read it, so the rules are only worth re-reading once both have + // had a moment. + settle.restart(); + } + } + + Timer { + id: settle + interval: 350 + onTriggered: root.refresh() + } + + function refresh(): void { + if (!query.running) + query.running = true; + } + + // Write the choice, then make it true. Split from the write on purpose: the + // page confirms between the two, because a reload is felt across the whole + // session rather than in one card. + function choose(primaryOnly: bool): void { + DesktopPreferences.set("workspacesOnPrimaryOnly", primaryOnly === true); + } + + function apply(): void { + if (reloadRun.running) + return; + root.reloading = true; + reloadRun.running = true; + } + + Component.onCompleted: root.refresh() +} diff --git a/config/local/share/vicinae/scripts/settings-displays b/config/local/share/vicinae/scripts/settings-displays index ac40249..b1b9796 100755 --- a/config/local/share/vicinae/scripts/settings-displays +++ b/config/local/share/vicinae/scripts/settings-displays @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Displays in Settings. -# @vicinae.keywords ["settings", "game-aware hdr", "variable refresh rate", "direct scanout", "night light", "schedule automatically", "color temperature", "turns on at", "turns off at", "arrange displays", "monitor position", "primary display"] +# @vicinae.keywords ["settings", "workspaces on the primary display only", "game-aware hdr", "variable refresh rate", "direct scanout", "night light", "schedule automatically", "color temperature", "turns on at", "turns off at", "arrange displays", "monitor position", "primary display"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page displays diff --git a/docs/settings.md b/docs/settings.md index 1aed787..67307ff 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,7 +4,7 @@ Do not edit this file. Run `quickshell/scripts/panama-settings-docs` after changing the schema; a contract fails when this copy is stale. -136 settings across 27 groups. 70 of them are applied to the compositor and confirmed by reading the value back. +137 settings across 27 groups. 70 of them are applied to the compositor and confirmed by reading the value back. ## accessibility @@ -55,6 +55,7 @@ Found on **Displays**. | Setting | Default | What it does | |---|---|---| +| **Workspaces on the primary display only**
`workspacesOnPrimaryOnly` | false | Other screens keep one workspace of their own rather than switching along with it | | **Game-aware HDR**
`autoHdr` `render:cm_auto_hdr` | true | Hand HDR to fullscreen games while the desktop stays SDR | | **Variable refresh rate**
`vrrPolicy` `misc:vrr` | 3 | Matches the display's refresh rate to what is on screen Choices: Off, Always on, Fullscreen only, Fullscreen games. | | **Direct scanout**
`directScanoutPolicy` `render:direct_scanout` | 2 | Lets fullscreen content bypass compositing Choices: Off, Always on, Automatic. | diff --git a/tests/hypr/workspace-rules-contract b/tests/hypr/workspace-rules-contract new file mode 100755 index 0000000..7295c22 --- /dev/null +++ b/tests/hypr/workspace-rules-contract @@ -0,0 +1,125 @@ +#!/usr/bin/env bash + +# Workspaces on the primary display only. +# +# monitors.lua turns one preference into workspace rules, and the rules are the +# part that cannot be taken back: Hyprland reads them at config time and offers +# no way to remove one afterwards -- an empty monitor leaves the old binding in +# place, which was checked rather than assumed. Only a reload clears them, so +# what this file emits IS the state of the desktop, and emitting one rule too +# many strands a workspace on a screen until the next reload. +# +# The Lua is exercised with a stubbed `hl`, so the rules can be counted without +# a compositor and without touching the running desktop. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +hypr_dir="$repo_dir/config/dot/hypr" +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml" +service="$repo_dir/config/dot/quickshell/services/Workspaces.qml" +keybinds="$hypr_dir/keybinds.lua" + +findings=() +note() { findings+=("$1"); } + +command -v lua >/dev/null 2>&1 || { + printf 'workspace rules contract: lua is not installed\n' >&2 + exit 1 +} + +work="$(mktemp -d /tmp/panama-workspace-rules.XXXXXX)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/config/panama" + +# Every workspace rule monitors.lua emits for a given settings file, as +# "workspace monitor" lines. hl.monitor is swallowed: this is about workspaces, +# and a monitor call is not one. +emit() { + printf '%s' "$1" >"$work/config/panama/settings.json" + XDG_CONFIG_HOME="$work/config" lua -e " + package.path = '$hypr_dir/?.lua;' .. package.path + hl = { + monitor = function() end, + workspace_rule = function(rule) + print(tostring(rule.workspace) .. ' ' .. tostring(rule.monitor)) + end, + } + dofile('$hypr_dir/monitors.lua') + " 2>/dev/null +} + +PRIMARY='{"displays":{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0,"x":0,"y":0,"primary":true}}' + +# ── Off: Hyprland's own behaviour, which needs no rules at all ──────────────── + +off="$(emit "$PRIMARY,\"workspacesOnPrimaryOnly\":false}")" +[[ -z "$off" ]] || note "with the setting off, $(wc -l <<<"$off") workspace rules are still emitted" + +absent="$(emit "$PRIMARY}")" +[[ -z "$absent" ]] || note 'with the setting absent, workspace rules are emitted anyway' + +# ── On: every workspace the keybinds reach, and no more ────────────────────── + +on="$(emit "$PRIMARY,\"workspacesOnPrimaryOnly\":true}")" +emitted="$(grep -c . <<<"$on" || true)" + +# The count is not a magic number: it is how many workspaces ALT+1..ALT+0 reach. +# If keybinds.lua ever binds a different number, pinning the old count leaves +# some workspaces pinned and others not, which is worse than either. +bound="$(sed -n 's/.*for i = 1, \([0-9]*\) do.*/\1/p' "$keybinds" | head -1)" +[[ -n "$bound" ]] || bound=10 + +(( emitted == bound )) \ + || note "the setting pins $emitted workspaces but the keybinds reach $bound" + +while read -r workspace monitor; do + [[ -n "$workspace" ]] || continue + [[ "$monitor" == "DP-2" ]] \ + || note "workspace $workspace is pinned to '$monitor' rather than the primary display" +done <<<"$on" + +# ── On, with nothing to pin to ─────────────────────────────────────────────── +# +# A machine can have the preference set and no primary recorded -- it is the +# state this one is in. Guessing a primary would move every workspace onto +# whichever output happened to sort first. + +no_primary="$(emit '{"workspacesOnPrimaryOnly":true}')" +[[ -z "$no_primary" ]] \ + || note 'with no primary display recorded, workspaces are pinned to a guess' + +# ── The preference cannot pretend to be an option ──────────────────────────── + +if grep -q 'key: "workspacesOnPrimaryOnly"' "$schema"; then + block="$(sed -n '/key: "workspacesOnPrimaryOnly"/,/^ },/p' "$schema")" + grep -q 'hypr:' <<<"$block" \ + && note 'workspacesOnPrimaryOnly declares a hypr option, but workspace rules are not settable options' +else + note 'workspacesOnPrimaryOnly is not in the schema' +fi + +# ── The page tells the truth ───────────────────────────────────────────────── + +grep -q 'Displays.monitors.length >= 2' "$page" \ + || note 'the Workspaces card is not hidden on a single-display machine' + +# Applied has to be read back from the compositor. Inferring it from the +# preference having been written is how a page comes to claim a setting is in +# effect when it is waiting on a reload. +grep -q 'hyprctl", "-j", "workspacerules' "$service" \ + || note 'the service never reads the compositor, so it cannot know whether the setting took effect' +grep -q '"hyprctl", "reload"' "$service" \ + || note 'the service has no way to apply the setting' + +# ── Report ─────────────────────────────────────────────────────────────────── + +if (( ${#findings[@]} > 0 )); then + mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) + printf 'workspace rules contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'workspace rules contract: PASS (%d workspaces pinned when enabled)\n' "$emitted"