Bound the notification app list, and give Focus a real editor

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 14:25:26 -04:00
parent 07db1068f1
commit d5b6e62515
21 changed files with 1405 additions and 453 deletions
@@ -1,62 +1,219 @@
// The notifications tab: what interrupts, how long it stays, and which
// applications are allowed to do it.
//
// The application list used to be every application that had ever notified,
// rendered in full, forever. On a machine that has been running for a year that
// is a wall nobody reads. It is now bounded: recent senders and applications
// whose rule has been changed sit up top, and everything else waits behind a
// searchable expander. Rows unfold in place into the rule itself.
//
// Focus modes moved to their own tab. What stays here is the one line saying a
// mode is currently quieting things, because that is the question this page is
// opened to answer.
import QtQuick
import qs.config
import qs.services
import qs.modules.clipboard
SettingsPage {
id: root
// Only one application is expanded at a time; the point is a card you
// can read, not twenty open at once.
// One application is unfolded at a time; the point is a card you can read,
// not twenty open at once.
property string expandedApp: ""
property bool allAppsOpen: false
// Which focus mode is open for editing. One at a time, like the app rules.
property string expandedMode: ""
readonly property int weekMs: 7 * 24 * 60 * 60 * 1000
// Which mode's exception list is open. Separate from expandedMode so the
// list does not unfold every time a mode is opened to change a switch.
property string expandedAllow: ""
function toggleAllowed(mode: var, appId: string, allowed: bool): void {
const current = Array.isArray(mode.allow) ? mode.allow.map(String) : [];
const at = current.indexOf(appId);
if (allowed && at < 0)
current.push(appId);
else if (!allowed && at >= 0)
current.splice(at, 1);
FocusModes.update(String(mode.id), { allow: current });
// Every rule write leaves through here, so a change made in a row goes down
// exactly the same path as one made anywhere else.
function editRule(appId: string, patch: var): void {
Notifs.setAppRule(appId, patch);
}
// Schedule edits go through the mode's trigger list rather than replacing
// it, so a mode that also has a game or fullscreen trigger keeps it.
function reschedule(mode: var, changes: var): void {
FocusModes.update(String(mode.id), {
triggers: (mode.triggers ?? []).map(trigger =>
trigger.kind === "schedule" ? Object.assign({}, trigger, changes) : trigger)
});
function forgetRule(appId: string): void {
if (root.expandedApp === appId)
root.expandedApp = "";
Notifs.forgetApp(appId);
}
function toggleDay(mode: var, day: int): void {
const schedule = (mode.triggers ?? []).find(trigger => trigger.kind === "schedule");
// A rule that differs from the defaults in any field. This is what earns an
// application a place above the fold even when it has not notified lately.
function customized(rule: var): bool {
return rule.enabled === false
|| rule.sound === false
|| String(rule.display ?? "banners") !== "banners"
|| String(rule.urgency ?? "auto") !== "auto";
}
// Relative rather than a timestamp: "three days ago" is the question being
// asked, and a clock time from last March answers a different one.
function lastSeenText(lastSeenMs: var): string {
const at = Number(lastSeenMs ?? 0);
if (!at)
return "";
const days = Math.floor((Date.now() - at) / 86400000);
if (days <= 0)
return "Today";
if (days === 1)
return "Yesterday";
if (days < 7)
return days + " days ago";
return Qt.formatDateTime(new Date(at), "d MMM");
}
// Spelled the same way the Focus tab spells it, so the two pages describe
// one schedule in one voice.
function daysText(days: var): string {
const list = Array.isArray(days) ? days.map(Number).slice().sort((a, b) => a - b) : [];
if (list.length === 0)
return "no days";
if (list.length === 7)
return "every day";
if (list.join(",") === "1,2,3,4,5")
return "weekdays";
if (list.join(",") === "0,6")
return "weekends";
const names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
return list.map(day => names[day]).join(", ");
}
// Quiet hours are the Sleep mode's schedule. Rather than inventing a second
// place to set them, the row states what Sleep is actually doing and hands
// over to the tab that owns it.
function quietHoursDetail(): string {
const sleep = FocusModes.modes.find(mode => String(mode.id) === "sleep");
if (!sleep)
return "There is no Sleep focus mode, so nothing is scheduled";
const schedule = (sleep.triggers ?? []).find(trigger => trigger.kind === "schedule");
if (!schedule)
return;
const days = Array.isArray(schedule.days) ? schedule.days.slice() : [];
const at = days.indexOf(day);
if (at >= 0)
days.splice(at, 1);
else
days.push(day);
days.sort((a, b) => a - b);
root.reschedule(mode, { days: days });
return "The Sleep focus mode has no schedule, so quiet hours are not scheduled";
const when = String(schedule.start ?? "") + " to " + String(schedule.end ?? "")
+ ", " + root.daysText(schedule.days);
return sleep.enabled === true
? "Scheduled through the Sleep focus mode — " + when
: "The Sleep focus mode is off; it would run " + when;
}
title: "Notifications & Focus"
readonly property var recentApps: {
const now = Date.now();
return Notifs.applications
.filter(app => Number(app.lastSeenMs ?? 0) > 0
&& now - Number(app.lastSeenMs) < root.weekMs)
.sort((a, b) => Number(b.lastSeenMs) - Number(a.lastSeenMs));
}
// Sections are partitioned by id rather than by object identity: these are
// plain objects rebuilt by a binding, and an application appearing in two
// sections at once is the failure that would cause.
readonly property var recentIds: root.recentApps.map(app => String(app.id))
readonly property var customizedApps: Notifs.applications.filter(app =>
root.recentIds.indexOf(String(app.id)) < 0 && root.customized(app.rule))
readonly property var customizedIds: root.customizedApps.map(app => String(app.id))
// Everything not already on screen. Notifs.applications arrives sorted by
// name, so this stays alphabetical for free.
readonly property var otherApps: Notifs.applications.filter(app =>
root.recentIds.indexOf(String(app.id)) < 0
&& root.customizedIds.indexOf(String(app.id)) < 0)
// With nothing typed the expander holds what is not shown above. A typed
// query searches the whole universe instead -- a search box that cannot
// find an application you can see is worse than a duplicated row.
readonly property var expanderApps: {
const needle = appSearch.text.trim().toLowerCase();
if (needle === "")
return root.otherApps;
return Notifs.applications.filter(app =>
(String(app.name) + " " + String(app.id)).toLowerCase().indexOf(needle) >= 0);
}
readonly property string allowSummary: {
const allow = FocusModes.allowedApps;
if (allow.length === 0)
return "nothing may interrupt";
const names = allow.map(id => {
const app = Notifs.applications.find(entry => String(entry.id) === id);
return app ? String(app.name) : id;
});
if (names.length <= 2)
return names.join(" and ") + " may interrupt";
return names.slice(0, 2).join(", ") + " and " + (names.length - 2)
+ " more may interrupt";
}
title: "Notifications"
lede: "Control interruptions without losing useful history."
// Why the machine is quiet, at the top, where the question is asked.
Rectangle {
id: activeBanner
width: parent.width
visible: FocusModes.activeMode !== null
height: visible ? Math.max(58, bannerCopy.implicitHeight + 24) : 0
radius: Theme.cardRadius + 1
color: Theme.alpha(Theme.ok, 0.08)
border.width: 1
border.color: Theme.alpha(Theme.ok, 0.3)
Column {
id: bannerCopy
anchors.left: parent.left
anchors.leftMargin: 15
anchors.right: bannerButton.left
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: FocusModes.activeMode?.silence === true
? FocusModes.activeName + " is quieting notifications"
: FocusModes.activeName + " is on"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: {
const why = "Because " + FocusModes.activeReason;
return FocusModes.activeMode?.silence === true
? why + " · " + root.allowSummary
: why + " · notifications are unaffected";
}
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
SettingsButton {
id: bannerButton
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "Open Focus"
onClicked: ShellState.openSettings("focus")
}
}
SettingsCard {
title: "Notifications"
title: "Quiet"
SettingRow {
label: "Do Not Disturb"
detail: "Keep notifications in the center but suppress banners"
detail: "Banners stop; everything still lands in the notification center"
controlWidth: 48
SettingsToggle {
@@ -67,37 +224,43 @@ SettingsPage {
}
}
TextRow {
label: "Notification history"
detail: "Live notifications retained by the shell"
value: Notifs.history.length === 1 ? "1 item" : `${Notifs.history.length} items`
}
ToggleRow { setting: "criticalBreaksThrough" }
ActionRow {
label: "Clear notification history"
detail: "Dismiss every item currently in the notification center"
label: "Quiet hours"
detail: root.quietHoursDetail()
action: "Open Focus"
divider: false
action: "Clear all"
enabled: Notifs.history.length > 0
onTriggered: Notifs.dismissAll()
onTriggered: ShellState.openSettings("focus")
}
}
SettingsCard {
title: "Banner behavior"
title: "Banners & history"
SliderRow { setting: "notificationTimeoutMs" }
SliderRow {
setting: "notificationTimeoutCriticalMs"
zeroLabel: "Never"
}
SliderRow { setting: "maxVisibleToasts" }
SliderRow { setting: "notificationHistoryLimit" }
SliderRow { setting: "maxVisibleToasts"; divider: false }
ActionRow {
label: "Notification history"
detail: Notifs.history.length === 1
? "1 kept now · the oldest are dropped past the limit"
: `${Notifs.history.length} kept now · the oldest are dropped past the limit`
action: "Clear"
enabled: Notifs.history.length > 0
divider: false
onTriggered: Notifs.dismissAll()
}
}
SettingsCard {
title: "Application rules"
subtitle: "Apps appear here after they send a notification."
title: "Applications"
subtitle: "Apps appear after their first notification. Recent senders and apps you have customized stay up top; the rest wait in All apps."
TextRow {
visible: Notifs.applications.length === 0
@@ -106,332 +269,155 @@ SettingsPage {
divider: false
}
Repeater {
model: Notifs.applications
Item {
width: parent.width
height: root.recentApps.length > 0 ? 28 : 0
visible: height > 0
Column {
id: appEntry
Text {
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.bottomMargin: 4
text: "Recent"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
font.weight: Font.Bold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.8
}
}
Repeater {
model: root.recentApps
delegate: NotificationAppRow {
id: recentRow
required property var modelData
readonly property var app: appEntry.modelData
readonly property var rule: Notifs.appRule(appEntry.app.id)
width: parent.width
SettingRow {
width: parent.width
label: appEntry.app.name
detail: appEntry.rule.enabled ? String(appEntry.app.id) : "Notifications off"
divider: true
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: appEntry.rule.enabled
onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value })
}
}
appId: String(recentRow.modelData.id)
appName: String(recentRow.modelData.name)
iconName: String(recentRow.modelData.icon ?? "")
rule: Notifs.appRule(recentRow.modelData.id)
lastSeen: root.lastSeenText(recentRow.modelData.lastSeenMs)
expanded: root.expandedApp === String(recentRow.modelData.id)
onActivated: root.expandedApp = recentRow.expanded
? ""
: String(recentRow.modelData.id)
onEdited: patch => root.editRule(String(recentRow.modelData.id), patch)
onForgotten: root.forgetRule(String(recentRow.modelData.id))
}
}
}
SettingsCard {
title: "Focus modes"
subtitle: FocusModes.active
? FocusModes.activeName + " is on because " + FocusModes.activeReason + "."
: "What quiets this machine, and what turns it on. The first mode whose condition is true wins, so order is priority."
Item {
width: parent.width
height: root.customizedApps.length > 0 ? 28 : 0
visible: height > 0
Text {
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.bottomMargin: 4
text: "Customized"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
font.weight: Font.Bold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.8
}
}
Repeater {
model: FocusModes.modes
model: root.customizedApps
delegate: Column {
id: modeEntry
delegate: NotificationAppRow {
id: customRow
required property var modelData
readonly property var mode: modeEntry.modelData
readonly property string modeId: String(modeEntry.mode.id ?? "")
readonly property bool open: root.expandedMode === modeEntry.modeId
readonly property bool running: FocusModes.activeMode?.id === modeEntry.modeId
readonly property var schedule: (modeEntry.mode.triggers ?? [])
.find(trigger => trigger.kind === "schedule") ?? null
width: parent.width
appId: String(customRow.modelData.id)
appName: String(customRow.modelData.name)
iconName: String(customRow.modelData.icon ?? "")
rule: Notifs.appRule(customRow.modelData.id)
lastSeen: root.lastSeenText(customRow.modelData.lastSeenMs)
expanded: root.expandedApp === String(customRow.modelData.id)
onActivated: root.expandedApp = customRow.expanded
? ""
: String(customRow.modelData.id)
onEdited: patch => root.editRule(String(customRow.modelData.id), patch)
onForgotten: root.forgetRule(String(customRow.modelData.id))
}
}
SettingRow {
visible: root.otherApps.length > 0
label: `All apps (${root.otherApps.length})`
detail: "The rest of what has notified, alphabetically — search matches a name or an id"
activatable: true
divider: !root.allAppsOpen
controlWidth: 40
onActivated: root.allAppsOpen = !root.allAppsOpen
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.allAppsOpen ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Item {
width: parent.width
height: root.allAppsOpen ? 42 : 0
visible: root.allAppsOpen
SearchField {
id: appSearch
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
placeholder: "Search apps"
}
}
Repeater {
model: root.allAppsOpen ? root.expanderApps : []
delegate: NotificationAppRow {
id: expanderRow
required property var modelData
required property int index
width: parent.width
SettingRow {
width: parent.width
label: String(modeEntry.mode.name ?? "")
detail: modeEntry.running
? "On now — " + FocusModes.activeReason
: FocusModes.summary(modeEntry.mode)
activatable: true
divider: !modeEntry.open
controlWidth: 92
onActivated: root.expandedMode = modeEntry.open ? "" : modeEntry.modeId
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 11
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: modeEntry.mode.enabled === true
onToggled: value => FocusModes.setEnabled(modeEntry.modeId, value)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: modeEntry.open ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
SettingRow {
width: parent.width
visible: modeEntry.open
label: "Silence notifications"
detail: "Banners are held until the mode ends"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: modeEntry.mode.silence === true
onToggled: value => FocusModes.update(modeEntry.modeId, { silence: value })
}
}
SettingRow {
width: parent.width
visible: modeEntry.open
label: "Keep the screen awake"
detail: "Caffeine, for as long as the mode is on"
controlWidth: 48
divider: modeEntry.schedule !== null
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: modeEntry.mode.keepAwake === true
onToggled: value => FocusModes.update(modeEntry.modeId, { keepAwake: value })
}
}
// Only modes that actually have a schedule get the schedule
// controls; a game trigger has no times to set.
TextFieldRow {
width: parent.width
visible: modeEntry.open && modeEntry.schedule !== null
label: "From"
detail: "24-hour, such as 23:30"
text: String(modeEntry.schedule?.start ?? "")
placeholder: "23:30"
onAccepted: value => root.reschedule(modeEntry.mode, { start: value })
}
TextFieldRow {
width: parent.width
visible: modeEntry.open && modeEntry.schedule !== null
label: "Until"
detail: "A time earlier than the start means the window crosses midnight"
text: String(modeEntry.schedule?.end ?? "")
placeholder: "07:00"
onAccepted: value => root.reschedule(modeEntry.mode, { end: value })
}
SettingRow {
width: parent.width
visible: modeEntry.open && modeEntry.mode.silence === true
label: "May interrupt"
detail: (modeEntry.mode.allow ?? []).length === 0
? "Nothing gets through while this mode is on"
: "Everything else is held until the mode ends"
activatable: true
controlWidth: 150
onActivated: root.expandedAllow =
root.expandedAllow === modeEntry.modeId ? "" : modeEntry.modeId
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
Text {
anchors.verticalCenter: parent.verticalCenter
text: (modeEntry.mode.allow ?? []).length === 0
? "Nothing"
: (modeEntry.mode.allow ?? []).length + " apps"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.expandedAllow === modeEntry.modeId ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
// Drawn from the applications that have actually sent a
// notification, which is the same list the rules above use --
// an exception for something that never notifies is not a
// choice worth offering.
Repeater {
model: (modeEntry.open && root.expandedAllow === modeEntry.modeId)
? Notifs.applications
: []
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.id ?? "")
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: (modeEntry.mode.allow ?? []).indexOf(String(modelData.id)) >= 0
onToggled: value => root.toggleAllowed(modeEntry.mode, String(modelData.id), value)
}
}
}
TextRow {
width: parent.width
visible: modeEntry.open && root.expandedAllow === modeEntry.modeId
&& Notifs.applications.length === 0
label: "No applications yet"
detail: "They appear here once they have sent a notification."
value: ""
}
SettingRow {
width: parent.width
visible: modeEntry.open && modeEntry.schedule !== null
label: "On these days"
detail: "A window that crosses midnight belongs to the day it starts on"
controlWidth: 250
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 5
Repeater {
model: [
{ value: 1, label: "M" }, { value: 2, label: "T" },
{ value: 3, label: "W" }, { value: 4, label: "T" },
{ value: 5, label: "F" }, { value: 6, label: "S" },
{ value: 0, label: "S" }
]
delegate: Rectangle {
id: dayPill
required property var modelData
readonly property bool on:
(modeEntry.schedule?.days ?? []).indexOf(dayPill.modelData.value) >= 0
width: 30
height: 28
radius: 8
color: dayPill.on ? Theme.alpha(Theme.accent, 0.22)
: Theme.alpha(Theme.fg, 0.06)
border.width: dayPill.on ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
anchors.centerIn: parent
text: String(dayPill.modelData.label)
color: dayPill.on ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: dayPill.on ? Font.DemiBold : Font.Medium
}
HoverHandler { cursorShape: Qt.PointingHandCursor }
TapHandler {
onTapped: root.toggleDay(modeEntry.mode, dayPill.modelData.value)
}
}
}
}
}
}
}
}
SettingsCard {
title: "Focus sessions"
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
SettingRow {
label: "Keep the screen awake"
detail: Caffeine.enabled
? "The display will not blank or lock while this is on"
: "Idle timings on Power & Lock apply normally"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Caffeine.enabled
onToggled: value => Caffeine.enabled = value
appId: String(expanderRow.modelData.id)
appName: String(expanderRow.modelData.name)
iconName: String(expanderRow.modelData.icon ?? "")
rule: Notifs.appRule(expanderRow.modelData.id)
lastSeen: root.lastSeenText(expanderRow.modelData.lastSeenMs)
expanded: root.expandedApp === String(expanderRow.modelData.id)
divider: expanderRow.index < root.expanderApps.length - 1
onActivated: root.expandedApp = expanderRow.expanded
? ""
: String(expanderRow.modelData.id)
onEdited: patch => root.editRule(String(expanderRow.modelData.id), patch)
onForgotten: root.forgetRule(String(expanderRow.modelData.id))
}
}
SettingRow {
id: durationRow
label: "Default duration"
detail: "Used by Super+Shift+F and Quick Settings"
controlWidth: 264
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Repeater {
model: [25, 45, 60, 90]
SettingsButton {
required property int modelData
text: `${modelData}m`
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
}
}
}
}
SettingRow {
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
TextRow {
visible: root.allAppsOpen && root.expanderApps.length === 0
label: appSearch.text.trim() === "" ? "Nothing else remembered" : "No matching applications"
detail: appSearch.text.trim() === ""
? "Every application that has notified is already listed above."
: "Search matches an application's name or its id."
divider: false
controlWidth: 120
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.active ? "Show controls" : "Start focus"
tone: FocusSession.active ? "normal" : "accent"
onClicked: FocusSession.reveal()
}
}
}
}