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"