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"