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"