From cceb7a707b58d0a33540f8006318c3cc48cfcfa0 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 14 Sep 2026 14:36:24 -0400 Subject: [PATCH] Fix: Never pair exclusionMode with exclusiveZone Quickshell's exclusiveZone setter flips exclusionMode back to Normal as a side effect, so a window declaring both is at the mercy of which property the QML engine applies last. The 2026-09-14 Qt update changed that order and every full-screen overlay slid under the bar. Each overlay now declares ExclusionMode.Ignore alone. The capture picker was the visible failure: it started under the bar, the full-output freeze frame was squeezed into a shorter box, and every selection landed one bar-height off in the real capture. tests/quickshell/exclusion-idiom-contract fails any file that pairs them. --- README.md | 2 +- .../modules/capture/CaptureOverlay.qml | 10 +++-- .../modules/cheatsheet/Cheatsheet.qml | 1 - config/dot/quickshell/modules/dock/Dock.qml | 1 - .../modules/dock/DockPickOverlay.qml | 1 - .../modules/notifications/VisualBell.qml | 1 - config/dot/quickshell/modules/osd/Osd.qml | 1 - .../quickshell/modules/overview/Overview.qml | 1 - .../modules/settings/DisplayIdentify.qml | 1 - .../dot/quickshell/modules/settings/README.md | 7 +++ .../quickshell/modules/welcome/Welcome.qml | 1 - tests/contracts.manifest | 1 + tests/quickshell/exclusion-idiom-contract | 45 +++++++++++++++++++ 13 files changed, 61 insertions(+), 12 deletions(-) create mode 100755 tests/quickshell/exclusion-idiom-contract diff --git a/README.md b/README.md index 0b23afd..b30975c 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -189 of them, under `tests/`. `tests/contracts.manifest` classifies every +190 of them, under `tests/`. `tests/contracts.manifest` classifies every contract by the capabilities it needs. Run the hermetic set, or grant a specific external capability when automation needs it: diff --git a/config/dot/quickshell/modules/capture/CaptureOverlay.qml b/config/dot/quickshell/modules/capture/CaptureOverlay.qml index 6ced8cb..948f1b4 100644 --- a/config/dot/quickshell/modules/capture/CaptureOverlay.qml +++ b/config/dot/quickshell/modules/capture/CaptureOverlay.qml @@ -27,9 +27,13 @@ PanelWindow { right: true } - // Fullscreen overlays must not reserve space, or every window on the - // workspace gets resized as the picker opens and closes. - exclusiveZone: 0 + // Ignore, and nothing else: reserve no space (or every window on the + // workspace resizes as the picker opens and closes) and respect nobody's + // (or the window starts under the bar, the full-output freeze frame is + // squeezed into a shorter box, and every selection lands one bar-height + // off in the real capture). Never pair this with exclusiveZone; see + // tests/quickshell/exclusion-idiom-contract. + exclusionMode: ExclusionMode.Ignore WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua WlrLayershell.layer: WlrLayer.Overlay diff --git a/config/dot/quickshell/modules/cheatsheet/Cheatsheet.qml b/config/dot/quickshell/modules/cheatsheet/Cheatsheet.qml index a499f2c..39d4380 100644 --- a/config/dot/quickshell/modules/cheatsheet/Cheatsheet.qml +++ b/config/dot/quickshell/modules/cheatsheet/Cheatsheet.qml @@ -57,7 +57,6 @@ PanelWindow { anchors { top: true; bottom: true; left: true; right: true } color: "transparent" - exclusiveZone: 0 exclusionMode: ExclusionMode.Ignore // The `^qs-popover` prefix rule in hypr/rules.lua blurs what is behind diff --git a/config/dot/quickshell/modules/dock/Dock.qml b/config/dot/quickshell/modules/dock/Dock.qml index d41c1e7..d24b85e 100644 --- a/config/dot/quickshell/modules/dock/Dock.qml +++ b/config/dot/quickshell/modules/dock/Dock.qml @@ -55,7 +55,6 @@ PanelWindow { color: "transparent" // A dock that reserved space would not be intellihiding. - exclusiveZone: 0 exclusionMode: ExclusionMode.Ignore // Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename. diff --git a/config/dot/quickshell/modules/dock/DockPickOverlay.qml b/config/dot/quickshell/modules/dock/DockPickOverlay.qml index da7df99..c6cbd4c 100644 --- a/config/dot/quickshell/modules/dock/DockPickOverlay.qml +++ b/config/dot/quickshell/modules/dock/DockPickOverlay.qml @@ -40,7 +40,6 @@ PanelWindow { anchors { top: true; bottom: true; left: true; right: true } color: "transparent" - exclusiveZone: 0 exclusionMode: ExclusionMode.Ignore // Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted diff --git a/config/dot/quickshell/modules/notifications/VisualBell.qml b/config/dot/quickshell/modules/notifications/VisualBell.qml index 89ad180..483c9ad 100644 --- a/config/dot/quickshell/modules/notifications/VisualBell.qml +++ b/config/dot/quickshell/modules/notifications/VisualBell.qml @@ -44,7 +44,6 @@ PanelWindow { // Reserve nothing and respect nothing: the glow is drawn over the whole // output including under the bar and the dock, which is what makes it // visible from wherever the eyes happen to be. - exclusiveZone: 0 exclusionMode: ExclusionMode.Ignore color: "transparent" diff --git a/config/dot/quickshell/modules/osd/Osd.qml b/config/dot/quickshell/modules/osd/Osd.qml index 0446d92..e33c20c 100644 --- a/config/dot/quickshell/modules/osd/Osd.qml +++ b/config/dot/quickshell/modules/osd/Osd.qml @@ -29,7 +29,6 @@ PanelWindow { screen: root.modelData anchors.bottom: true margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3 - exclusiveZone: 0 exclusionMode: ExclusionMode.Ignore implicitWidth: root.desiredWidth implicitHeight: 64 diff --git a/config/dot/quickshell/modules/overview/Overview.qml b/config/dot/quickshell/modules/overview/Overview.qml index 4d70d86..f61e844 100644 --- a/config/dot/quickshell/modules/overview/Overview.qml +++ b/config/dot/quickshell/modules/overview/Overview.qml @@ -22,7 +22,6 @@ PanelWindow { } color: "transparent" - exclusiveZone: 0 exclusionMode: ExclusionMode.Ignore // Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename. diff --git a/config/dot/quickshell/modules/settings/DisplayIdentify.qml b/config/dot/quickshell/modules/settings/DisplayIdentify.qml index 91047d7..8d69236 100644 --- a/config/dot/quickshell/modules/settings/DisplayIdentify.qml +++ b/config/dot/quickshell/modules/settings/DisplayIdentify.qml @@ -22,7 +22,6 @@ Variants { implicitWidth: 260 implicitHeight: 172 color: "transparent" - exclusiveZone: 0 exclusionMode: ExclusionMode.Ignore mask: Region {} diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md index 72ad0c3..1e57e44 100644 --- a/config/dot/quickshell/modules/settings/README.md +++ b/config/dot/quickshell/modules/settings/README.md @@ -383,6 +383,13 @@ Lua-configured Hyprland, prints the refusal to stdout, and exits 0. `eval` exits 0 on syntax and runtime errors too. The only trustworthy signal that a write landed is reading the value back. +**`exclusionMode: ExclusionMode.Ignore` goes alone.** Quickshell's `exclusiveZone` +setter flips `exclusionMode` back to `Normal` as a side effect, so a window that +declares both is at the mercy of which property the QML engine applies last. +The 2026-09-14 Qt update changed that order and every full-screen overlay slid +under the bar. `tests/quickshell/exclusion-idiom-contract` fails any file that +pairs them. + **The Settings window is tiled.** `implicitWidth` is a hint; the layout decides, and it ranges from a half-screen split to the full display. `SliderRow` stacks its control under the label below 520px. Test narrow. diff --git a/config/dot/quickshell/modules/welcome/Welcome.qml b/config/dot/quickshell/modules/welcome/Welcome.qml index 91468f1..123478c 100644 --- a/config/dot/quickshell/modules/welcome/Welcome.qml +++ b/config/dot/quickshell/modules/welcome/Welcome.qml @@ -78,7 +78,6 @@ PanelWindow { anchors { top: true; bottom: true; left: true; right: true } color: "transparent" - exclusiveZone: 0 exclusionMode: ExclusionMode.Ignore WlrLayershell.namespace: "qs-popover-welcome" diff --git a/tests/contracts.manifest b/tests/contracts.manifest index b2c752f..2551e98 100644 --- a/tests/contracts.manifest +++ b/tests/contracts.manifest @@ -82,6 +82,7 @@ live-desktop tests/quickshell/dock-position-contract # Reads Hyprland option descriptions from the live compositor to verify enum # mappings. live-compositor tests/quickshell/enum-hypr-map-contract +hermetic tests/quickshell/exclusion-idiom-contract hermetic tests/quickshell/fingerprint-contract # Reads the host firewall state through the production firewall helper. live-host tests/quickshell/firewall-contract diff --git a/tests/quickshell/exclusion-idiom-contract b/tests/quickshell/exclusion-idiom-contract new file mode 100755 index 0000000..e0b0f43 --- /dev/null +++ b/tests/quickshell/exclusion-idiom-contract @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +# A window that wants the whole output must ask for ExclusionMode.Ignore and +# nothing else. Quickshell's exclusiveZone setter forces exclusionMode back to +# Normal as a side effect, so a window declaring both +# +# exclusiveZone: 0 +# exclusionMode: ExclusionMode.Ignore +# +# ends up with whichever property the QML engine applied last, and that order +# is not ours to control: the 2026-09-14 Qt/Quickshell update flipped it, every +# full-screen overlay slid down under the bar, and the screenshot picker's +# frozen frame stopped lining up with the screen it was a picture of. This pins +# the order-independent idiom: Ignore alone, never paired with exclusiveZone. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +qs="$repo_dir/config/dot/quickshell" + +fail() { printf 'exclusion idiom contract: %s\n' "$1" >&2; exit 1; } +[[ -d "$qs/modules" ]] || fail "missing $qs/modules" + +python3 - "$qs" <<'PY' +import re, sys, pathlib +qs = pathlib.Path(sys.argv[1]) +problems = [] +ignore = re.compile(r'\bexclusionMode\s*:\s*ExclusionMode\.Ignore\b') +zone = re.compile(r'^\s*(?:WlrLayershell\.)?exclusiveZone\s*:', re.M) +checked = 0 +for path in sorted(qs.rglob('*.qml')): + text = path.read_text() + if not ignore.search(text): + continue + checked += 1 + for m in zone.finditer(text): + line = text.count('\n', 0, m.start()) + 1 + problems.append(f"{path.relative_to(qs)}:{line}: exclusiveZone set in a file that uses " + "ExclusionMode.Ignore; drop it, Ignore already means -1") +if checked == 0: + problems.append("no window uses ExclusionMode.Ignore; the overlays this pins are gone") +if problems: + print('\n'.join(problems), file=sys.stderr) + sys.exit(1) +PY