From 7e1c85b094d02d6f8ecd3930ba24bc643ea46307 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Wed, 19 Aug 2026 22:54:51 -0400 Subject: [PATCH] Group the long pages by what you are trying to do Notifications repeated both lock-screen switch labels for every application, so twenty apps meant sixty rows of the same two sentences and the page could not be scanned at all. Each app is one row now, carrying what its switches add up to -- "On, lock screen shows the sender only", "On, hidden on the lock screen", "Notifications off" -- with the switches behind it, one app open at a time. The identifier only appears while an app is open, which is the only time it disambiguates anything, and the content switch dims when the app cannot reach the lock screen at all, because there it means nothing. Shortcuts were already grouped; the problem was that "Windows" caught focus, movement, splitting, resizing and window state alike and held 43 of the 93 binds. A section that long is a list, not a grouping. They are separated by intent now -- Focus, Move & split, Size, Window state -- and the split was checked against the binds this machine actually has rather than trusted from the keywords. Order matters in two places worth naming: "Next window splits down" is about splitting rather than focus, and "Focus session" is quiet mode bound to a workspace rather than window focus, so both are settled before the general checks. Refresh rate gets its own row. That need was created by collapsing the resolution list: the rates for a resolution were only ever reachable by opening it, so changing nothing but the rate meant going through the mode you already had. It appears only when the current resolution offers more than one. Default-application rows carry a chevron, having previously opened a chooser while looking completely inert. The notification contract asserted the literal Notifs.appRule(app.id).enabled, which moved when the rows collapsed. The rule is still read through a binding on Notifs.appRule, so a rule changed elsewhere still reaches the row -- the assertion now requires that, rather than requiring one particular spelling of it. The power profile rows were left alone. A three-way choice in three rows looks wasteful until you notice each row explains what the profile does, and that page has empty space to spare; a segmented control would trade information for space that is not scarce. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../modules/settings/ApplicationsPage.qml | 42 +++++++++-- .../modules/settings/DisplaysPage.qml | 45 ++++++++++++ .../modules/settings/NotificationsPage.qml | 72 ++++++++++++++++--- config/dot/quickshell/services/Keybinds.qml | 46 +++++++++--- .../notification-app-rules-contract.sh | 7 +- 5 files changed, 187 insertions(+), 25 deletions(-) diff --git a/config/dot/quickshell/modules/settings/ApplicationsPage.qml b/config/dot/quickshell/modules/settings/ApplicationsPage.qml index 05172c1..ee51507 100644 --- a/config/dot/quickshell/modules/settings/ApplicationsPage.qml +++ b/config/dot/quickshell/modules/settings/ApplicationsPage.qml @@ -112,14 +112,46 @@ SettingsPage { width: parent.width SettingRow { + id: roleRow + + readonly property bool open: root.expandedRole === roleBlock.modelData.key + label: roleBlock.modelData.label detail: roleBlock.modelData.detail - value: DefaultApps.busy ? "Loading…" : ( - roleBlock.selectedEntry - ? root.displayName(roleBlock.selectedEntry) - : (root.currentHandler(roleBlock.modelData.key) || "Not set") - ) activatable: roleBlock.choices.length > 0 && !DefaultApps.busy + controlWidth: 210 + + // Drawn rather than left to SettingRow's plain value text, so + // the row carries the same chevron a PickerRow does. These + // open a chooser but looked completely inert without it. + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 9 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: DefaultApps.busy ? "Loading…" : ( + roleBlock.selectedEntry + ? root.displayName(roleBlock.selectedEntry) + : (root.currentHandler(roleBlock.modelData.key) || "Not set") + ) + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + elide: Text.ElideRight + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: roleBlock.choices.length > 0 + text: roleRow.open ? "\u25B4" : "\u25BE" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1 onActivated: { root.expandedRole = root.expandedRole === roleBlock.modelData.key diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 232979d..0f9987e 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -27,6 +27,15 @@ SettingsPage { ? root.monitor.mode : "" + // Every mode at the resolution in use, which is what a refresh-rate choice + // actually is: the same width and height at a different rate. + readonly property var ratesForCurrentResolution: { + if (!root.monitor || !root.monitor.modes) + return []; + return root.monitor.modes.filter(mode => mode.width === root.monitor.width + && mode.height === root.monitor.height); + } + function syncSelectedOutput(): void { if (!Displays.monitorNamed(root.selectedOutput)) root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : ""; @@ -195,6 +204,42 @@ SettingsPage { onPicked: modePicker.collapse() } } + + // Refresh rate on its own, because the rates for a resolution used to be + // reachable only by opening the resolution list -- which is now + // collapsed, so changing only the rate meant going through the mode you + // already had. + PickerRow { + id: ratePicker + + label: "Refresh rate" + detail: "Rates this display offers at " + (root.monitor + ? root.monitor.width + " × " + root.monitor.height + : "the current resolution") + value: root.monitor ? root.monitor.refreshRate.toFixed(2) + " Hz" : "" + visible: root.ratesForCurrentResolution.length > 1 + enabled: !Displays.awaitingConfirmation && !Displays.busy + divider: false + + Repeater { + model: root.ratesForCurrentResolution + + delegate: TextRow { + required property var modelData + required property int index + width: parent.width + label: String(modelData.refreshLabel ?? "") + value: Displays.modeIsCurrent(root.monitor, modelData) ? "Current" : "" + controlWidth: 90 + divider: index < root.ratesForCurrentResolution.length - 1 + activatable: !Displays.modeIsCurrent(root.monitor, modelData) + onActivated: { + root.applyWith({ mode: modelData.mode }); + ratePicker.collapse(); + } + } + } + } } SettingsCard { diff --git a/config/dot/quickshell/modules/settings/NotificationsPage.qml b/config/dot/quickshell/modules/settings/NotificationsPage.qml index 1ae4ee1..02f0676 100644 --- a/config/dot/quickshell/modules/settings/NotificationsPage.qml +++ b/config/dot/quickshell/modules/settings/NotificationsPage.qml @@ -3,6 +3,12 @@ import qs.config import qs.services SettingsPage { + id: root + + // Only one application is expanded at a time; the point is a card you + // can read, not twenty open at once. + property string expandedApp: "" + title: "Notifications & Focus" lede: "Control interruptions without losing useful history." @@ -65,26 +71,65 @@ SettingsPage { model: Notifs.applications Column { + id: appEntry + required property var modelData - readonly property var app: modelData + readonly property var app: appEntry.modelData + readonly property var rule: Notifs.appRule(appEntry.app.id) + readonly property bool open: root.expandedApp === String(appEntry.app.id) + + // What the two lock-screen switches add up to, so the common + // case -- reading rather than changing -- needs no interaction. + // Every app repeated both switch labels verbatim before this, + // which made twenty apps sixty rows of identical sentences. + readonly property string summary: { + if (!appEntry.rule.enabled) + return "Notifications off"; + if (!appEntry.rule.showOnLockScreen) + return "On · hidden on the lock screen"; + return appEntry.rule.showContentOnLockScreen + ? "On · lock screen shows content" + : "On · lock screen shows the sender only"; + } width: parent.width SettingRow { - label: app.name - detail: app.id - controlWidth: 48 + width: parent.width + label: appEntry.app.name + // The identifier is only worth the space while the app is + // open, which is the only time it disambiguates anything. + detail: appEntry.open ? String(appEntry.app.id) : appEntry.summary + activatable: true + divider: !appEntry.open + controlWidth: 92 + onActivated: root.expandedApp = appEntry.open ? "" : String(appEntry.app.id) - SettingsToggle { + Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - checked: Notifs.appRule(app.id).enabled - onToggled: value => Notifs.setAppRule(app.id, { enabled: value }) + spacing: 11 + + SettingsToggle { + anchors.verticalCenter: parent.verticalCenter + checked: appEntry.rule.enabled + onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value }) + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: appEntry.open ? "\u25B4" : "\u25BE" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } } } SettingRow { + width: parent.width + visible: appEntry.open label: "Show on lock screen" detail: "Allow this app's notifications on the lock screen" controlWidth: 48 @@ -92,22 +137,27 @@ SettingsPage { SettingsToggle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - checked: Notifs.appRule(app.id).showOnLockScreen - onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value }) + checked: appEntry.rule.showOnLockScreen + onToggled: value => Notifs.setAppRule(appEntry.app.id, { showOnLockScreen: value }) } } SettingRow { + width: parent.width + visible: appEntry.open label: "Show content on lock screen" detail: "Show message details when this app is visible there" divider: false controlWidth: 48 + // Meaningless unless the app reaches the lock screen at all. + opacity: appEntry.rule.showOnLockScreen ? 1 : 0.45 SettingsToggle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - checked: Notifs.appRule(app.id).showContentOnLockScreen - onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value }) + enabled: appEntry.rule.showOnLockScreen + checked: appEntry.rule.showContentOnLockScreen + onToggled: value => Notifs.setAppRule(appEntry.app.id, { showContentOnLockScreen: value }) } } } diff --git a/config/dot/quickshell/services/Keybinds.qml b/config/dot/quickshell/services/Keybinds.qml index 89ccfeb..5ad6641 100644 --- a/config/dot/quickshell/services/Keybinds.qml +++ b/config/dot/quickshell/services/Keybinds.qml @@ -277,30 +277,60 @@ Singleton { // Grouping is by what the shortcut does, taken from its own description, // so adding a bind puts it in the right section without touching this file. + // Which section a bind belongs to. + // + // "Windows" used to catch focus, movement, splitting, resizing and window + // state alike, which put 43 of the 93 binds under one heading -- a section + // that long is a list, not a grouping. The window verbs are separated here + // by what you are actually trying to do. + // + // Order matters: "Next window splits down" is about splitting rather than + // focus, and "Focus session" is a Panama feature rather than window focus, + // so both are settled before the general checks below them. function groupFor(description: string, bind: var): string { const text = description.toLowerCase(); if (bind.key && String(bind.key).indexOf("XF86") === 0) return "Media & hardware keys"; + + // Quiet mode and Caffeine bound to a workspace, not window focus. + if (text.indexOf("focus session") >= 0) + return "Applications & shell"; + if (text.indexOf("workspace") >= 0) return "Workspaces"; - if (text.indexOf("window") >= 0 || text.indexOf("focus") >= 0 - || text.indexOf("swap") >= 0 || text.indexOf("split") >= 0 - || text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0 + + if (text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0 || text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0 - || text.indexOf("shrink") >= 0 || text.indexOf("grow") >= 0 - || text.indexOf("float") >= 0 || text.indexOf("fullscreen") >= 0 - || text.indexOf("close") >= 0 || text.indexOf("scratchpad") >= 0) - return "Windows"; + || text.indexOf("shrink") >= 0 || text.indexOf("expand") >= 0 + || text.indexOf("grow") >= 0 || text.indexOf("resize") >= 0) + return "Size"; + + if (text.indexOf("split") >= 0 || text.indexOf("swap") >= 0 + || text.indexOf("move window") >= 0) + return "Move & split"; + + if (text.indexOf("close") >= 0 || text.indexOf("fullscreen") >= 0 + || text.indexOf("float") >= 0 || text.indexOf("pin ") >= 0 + || text.indexOf("scratchpad") >= 0 || text.indexOf("minimize") >= 0) + return "Window state"; + + if (text.indexOf("focus") >= 0 || text.indexOf("next window") >= 0 + || text.indexOf("previous window") >= 0 || text.indexOf("last window") >= 0 + || text.indexOf("window switch") >= 0) + return "Focus"; + if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0 || text.indexOf("track") >= 0 || text.indexOf("play") >= 0 || text.indexOf("brightness") >= 0) return "Media & hardware keys"; + return "Applications & shell"; } // Section order for the page. Anything a future bind invents lands at the // end rather than being dropped. - readonly property var groupOrder: ["Windows", "Workspaces", "Applications & shell", "Media & hardware keys"] + readonly property var groupOrder: ["Focus", "Move & split", "Size", "Window state", + "Workspaces", "Applications & shell", "Media & hardware keys"] function grouped(): var { const buckets = {}; diff --git a/tests/quickshell/notification-app-rules-contract.sh b/tests/quickshell/notification-app-rules-contract.sh index 5568454..aebd38f 100755 --- a/tests/quickshell/notification-app-rules-contract.sh +++ b/tests/quickshell/notification-app-rules-contract.sh @@ -97,7 +97,12 @@ for (const required of [ for (const required of [ "Notifs.applications", - "Notifs.appRule(app.id).enabled", + // Read through a binding on Notifs.appRule rather than a stored copy, so a + // rule changed elsewhere still reaches the row. The exact expression moved + // when the per-app rows were collapsed behind a summary; what must hold is + // that the call is still in a binding, not that it is spelled one way. + "Notifs.appRule(", + ".enabled", "showOnLockScreen", "showContentOnLockScreen", "Notifs.setAppRule"