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
This commit is contained in:
Gabriel Brown
2026-08-21 02:16:29 -04:00
parent 9092a80f66
commit 4c77bc2f61
8 changed files with 343 additions and 3 deletions
@@ -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",
@@ -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."
@@ -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()
}