From 79b3d5cb853e6af69186e12917c0eda55f4ad29e Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 20 Aug 2026 09:56:02 -0400 Subject: [PATCH] Close the sweep's last blind spot, and stop shortcuts silently colliding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gapsIn and gapsOut were the only two compositor settings the write sweep had never verified: Hyprland answers for them in CSS shorthand, "5 5 5 5", and the sweep had no way to compare that. The preference behind each is a single int that Hyprland expands to four sides, so a uniform reading compares exactly. A non-uniform one is not something the preference can express, and is skipped rather than collapsed to a number it never wrote. 63 of 63 verified live now, none skipped. Wallpaper thumbnails are cached. The report that five of them sat at "Loading…" was a screenshot taken 1.1 seconds after the page opened -- decoding one of these at tile size takes between 1.2 and 2.6 seconds and about ten start at once, which the code already said. Measuring it did turn up something real though: without a cache, scrolling back up pays that decode again for every tile. The tradeoff is a wallpaper replaced in place showing a stale thumbnail until restart, which is worth it for a directory of files that are added rather than edited. A chord already in use is now named rather than taken: "Super+Q is already Terminal". Two actions on one chord means whichever Hyprland reads last wins, which is not a thing to find out later by pressing it. Rebinding a shortcut to the chord it already holds is correctly not a conflict. Also: Open Appearance lands on the Windows tab now that the page has tabs, Storage points at reclaimable container space, and a dock row shows its desktop id only when two pinned applications share a name -- it is developer text, and repeating it under fifteen recognisable names made the list harder to scan. Written down because it cost the shell: QML has no default parameter values, and `function openSettings(page: string, section: string = "")` fails the entire configuration rather than the one function -- so the bar and dock went with it, and 43 contracts failed at once pointing at the same line. qmllint --bare passes that, which is why the usual check before touching the running shell did not catch it. openSettingsSection exists as a separate function for that reason. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../modules/settings/AppearancePage.qml | 8 +++++ .../modules/settings/DesktopPage.qml | 2 +- .../modules/settings/DockPinsEditor.qml | 9 +++++- .../modules/settings/ShortcutsPage.qml | 24 +++++++++++++- .../modules/settings/StoragePage.qml | 19 +++++++++++ .../modules/settings/WallpaperPicker.qml | 10 +++++- config/dot/quickshell/services/Keybinds.qml | 15 +++++++++ config/dot/quickshell/services/ShellState.qml | 24 ++++++++++++++ tests/quickshell/settings_write_sweep.py | 32 +++++++++++++++++-- 9 files changed, 137 insertions(+), 6 deletions(-) diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 60f9131..720171c 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -24,6 +24,14 @@ SettingsPage { // section down, below a wallpaper grid and the whole lock screen. property string tab: "theme" + // Arriving from another page that named a section opens on it. Taken once + // rather than bound, so the tabs still work normally afterwards. + Component.onCompleted: { + const section = ShellState.takeSettingsSection(); + if (section !== "") + root.tab = section; + } + title: "Appearance" lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale." diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index 1cedd44..0df8125 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -63,7 +63,7 @@ SettingsPage { detail: "Adjusted on the Appearance page, beside a live preview" action: "Open Appearance" divider: false - onTriggered: ShellState.openSettings("appearance") + onTriggered: ShellState.openSettingsSection("appearance", "windows") } } diff --git a/config/dot/quickshell/modules/settings/DockPinsEditor.qml b/config/dot/quickshell/modules/settings/DockPinsEditor.qml index b0fdd9b..1b160f6 100644 --- a/config/dot/quickshell/modules/settings/DockPinsEditor.qml +++ b/config/dot/quickshell/modules/settings/DockPinsEditor.qml @@ -76,7 +76,14 @@ Column { required property int index label: root.nameFor(pin.modelData) - detail: pin.modelData + // The desktop id is shown only when the name alone would not say + // which entry this is. It is developer text, and repeating it under + // fifteen recognisable application names is noise that makes the + // list harder to scan, not easier. + detail: root.pinned.filter(other => + root.nameFor(other) === root.nameFor(pin.modelData)).length > 1 + ? pin.modelData + : "" divider: pin.index < root.pinned.length - 1 controlWidth: 132 diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml index 388505c..5d6321a 100644 --- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -21,6 +21,11 @@ SettingsPage { // when nothing is being captured. Held here rather than per row so that // starting a new capture cancels any other. property string capturingChord: "" + + // The action already holding a chord somebody just pressed, and the chord + // itself. Held while the capture stays open so the message can name both. + property string conflict: "" + property string conflictChord: "" readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "") function xkbOptions(): var { @@ -170,11 +175,28 @@ SettingsPage { height: 30 visible: bindRow.capturing focus: bindRow.capturing + message: root.conflict === "" + ? "" + : root.conflictChord + " is already " + root.conflict + // A chord already in use is reported rather than + // taken. Two actions on one chord means whichever + // Hyprland happens to read last wins, which is not + // a thing to discover later by pressing it. onCaptured: chord => { + const taken = Keybinds.boundTo(chord, bindRow.modelData.luaChord); + if (taken !== "") { + root.conflict = taken; + root.conflictChord = chord; + return; + } + root.conflict = ""; Keybinds.rebind(bindRow.modelData.luaChord, chord); root.capturingChord = ""; } - onCanceled: root.capturingChord = "" + onCanceled: { + root.conflict = ""; + root.capturingChord = ""; + } } Row { diff --git a/config/dot/quickshell/modules/settings/StoragePage.qml b/config/dot/quickshell/modules/settings/StoragePage.qml index 5ae0ee3..a024fba 100644 --- a/config/dot/quickshell/modules/settings/StoragePage.qml +++ b/config/dot/quickshell/modules/settings/StoragePage.qml @@ -278,6 +278,25 @@ SettingsPage { } } + // Named here because this is the page somebody opens when space is short, + // and container images are usually the largest thing nobody remembers + // having. Only shown when there is actually something to reclaim. + SettingsCard { + visible: Containers.available && Containers.reclaimable > 0 + title: "Containers are holding " + Containers.formatBytes(Containers.reclaimable) + + ActionRow { + label: "Images and volumes nothing references" + detail: Containers.unusedImages.length + " image" + + (Containers.unusedImages.length === 1 ? "" : "s") + " and " + + Containers.unusedVolumes.length + " volume" + + (Containers.unusedVolumes.length === 1 ? "" : "s") + action: "Review" + divider: false + onTriggered: ShellState.settingsPage = "containers" + } + } + SettingsCard { title: "Removable drives" subtitle: "USB drives and memory cards." diff --git a/config/dot/quickshell/modules/settings/WallpaperPicker.qml b/config/dot/quickshell/modules/settings/WallpaperPicker.qml index f4c64b1..d2f892e 100644 --- a/config/dot/quickshell/modules/settings/WallpaperPicker.qml +++ b/config/dot/quickshell/modules/settings/WallpaperPicker.qml @@ -96,11 +96,19 @@ Item { source: "file://" + tile.modelData fillMode: Image.PreserveAspectCrop asynchronous: true - cache: false // Decode to roughly the size actually drawn. Without this a // grid of 12MB photographs decodes at full resolution. sourceSize.width: 400 sourceSize.height: 240 + // Cached, because these are slow: the largest of these files + // takes over two seconds to decode even at this size, and + // without a cache scrolling back up pays that again for + // every tile. What is held is the scaled thumbnail, not the + // original, so the cost of keeping it is small. The tradeoff + // is that a wallpaper replaced in place under the same name + // shows its old thumbnail until the shell restarts -- worth + // it for a directory of files that are added, not edited. + cache: true // Several of these are 8-12MB originals, so a tile can sit // empty for a second or two. Fading in on ready makes that diff --git a/config/dot/quickshell/services/Keybinds.qml b/config/dot/quickshell/services/Keybinds.qml index 5ad6641..fe629a1 100644 --- a/config/dot/quickshell/services/Keybinds.qml +++ b/config/dot/quickshell/services/Keybinds.qml @@ -332,6 +332,21 @@ Singleton { readonly property var groupOrder: ["Focus", "Move & split", "Size", "Window state", "Workspaces", "Applications & shell", "Media & hardware keys"] + // The action already bound to a chord, or "" if it is free. Compared on the + // form keybinds.lua writes rather than the prettified display form, because + // that is what a rebind is keyed by -- "SUPER + Q" and "Super+Q" are the + // same binding and must not read as two. + function boundTo(luaChord: string, exceptLuaChord: string): string { + const wanted = String(luaChord).replace(/\s+/g, "").toLowerCase(); + const skip = String(exceptLuaChord).replace(/\s+/g, "").toLowerCase(); + for (const bind of root.binds) { + const candidate = String(bind.luaChord).replace(/\s+/g, "").toLowerCase(); + if (candidate === wanted && candidate !== skip) + return String(bind.description); + } + return ""; + } + function grouped(): var { const buckets = {}; for (const bind of root.binds) { diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 8d9da7b..b05ea15 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -91,7 +91,31 @@ Singleton { root.activeOverlay = ""; } + // A section within the page, for pages that have tabs. Consumed once by the + // page and cleared, rather than bound to -- a binding would pin the tab and + // stop anyone changing it by hand once they arrived. + property string settingsSection: "" + + function takeSettingsSection(): string { + const section = root.settingsSection; + root.settingsSection = ""; + return section; + } + function openSettings(page: string): void { + root.settingsSection = ""; + root.showSettings(page); + } + + // Opening straight to a tab within a page. A separate function rather than a + // default argument: QML has no default parameter values, and writing one + // fails the whole configuration -- which takes the shell down with it. + function openSettingsSection(page: string, section: string): void { + root.settingsSection = section; + root.showSettings(page); + } + + function showSettings(page: string): void { const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "containers", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); diff --git a/tests/quickshell/settings_write_sweep.py b/tests/quickshell/settings_write_sweep.py index 3ee4bf6..c45d4f2 100644 --- a/tests/quickshell/settings_write_sweep.py +++ b/tests/quickshell/settings_write_sweep.py @@ -30,7 +30,14 @@ SCHEMA = "config/dot/quickshell/config/PreferenceSchema.qml" # Read-back shapes this sweep knows how to compare. A gradient or a vec2 has no # single scalar to diff, and guessing one would produce false failures; those # are reported as skipped rather than quietly counted as verified. -COMPARABLE = {"bool", "int", "float", "str"} +# +# "css" is the CSS-shorthand shape Hyprland uses for gaps: "5 5 5 5". The +# preference behind it is a single int that Hyprland expands to four sides, so +# a uniform reading compares exactly. A non-uniform one is not something the +# preference can express at all, and is skipped rather than collapsed to a +# number that would be wrong -- these two settings were the only compositor +# settings the sweep had never verified. +COMPARABLE = {"bool", "int", "float", "str", "css"} # Settings whose value this must not choose freely. # @@ -127,12 +134,33 @@ def read_option(entry: dict): payload = json.loads(result.stdout) except json.JSONDecodeError: return None - field = {"bool": "bool", "int": "int", "float": "float", "str": "str"}.get(entry["readAs"]) + field = {"bool": "bool", "int": "int", "float": "float", + "str": "str", "css": "css"}.get(entry["readAs"]) if field is None or field not in payload: return None + if entry["readAs"] == "css": + return css_scalar(payload[field]) return payload[field] +def css_scalar(text): + """The single number a CSS shorthand stands for, or None if it is not one. + + Hyprland answers "5 5 5 5" for a gap of five. The preference is one int, so + four equal sides read back exactly; four different ones mean something this + setting cannot have produced, and returning any one of them would report a + write as verified against a value it never wrote. + """ + parts = str(text or "").split() + if not parts: + return None + try: + numbers = [int(part) for part in parts] + except ValueError: + return None + return numbers[0] if len(set(numbers)) == 1 else None + + def stored_from_option(entry: dict, raw): """The value a preference would hold for this compositor reading.""" if entry["readAs"] == "bool":