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
This commit is contained in:
Gabriel Brown
2026-08-19 22:54:51 -04:00
parent 0c364f38e6
commit 7e1c85b094
5 changed files with 187 additions and 25 deletions
@@ -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
@@ -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 {
@@ -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 })
}
}
}
+38 -8
View File
@@ -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 = {};
@@ -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"