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":