From d5b6e62515f749416e4edde2becf45f4bbb94b26 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 24 Aug 2026 14:25:26 -0400 Subject: [PATCH] Bound the notification app list, and give Focus a real editor Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8 --- .../quickshell/config/PreferenceSchema.qml | 38 +- config/dot/quickshell/config/Settings.qml | 3 + .../notifications/NotificationCard.qml | 7 +- .../modules/settings/GamingPage.qml | 4 +- .../modules/settings/NotificationsPage.qml | 700 +++++++++--------- .../modules/settings/SettingsShell.qml | 2 + .../modules/settings/WorkspacesPage.qml | 6 - config/dot/quickshell/modules/settings/qmldir | 4 + config/dot/quickshell/services/FocusModes.qml | 134 ++++ config/dot/quickshell/services/Notifs.qml | 206 +++++- .../quickshell/services/SettingsRoutes.qml | 10 +- .../quickshell/services/SettingsSearch.qml | 12 +- .../vicinae/scripts/settings-notifications | 6 +- .../share/vicinae/scripts/settings-workspaces | 2 +- docs/settings.md | 9 +- ...26-08-23-settings-redesign-test-backlog.md | 101 +++ .../NotificationAppRulesHarness.qml | 136 ++++ tests/quickshell/focus-modes-contract | 132 +++- .../notification-app-rules-contract | 329 ++++++-- tests/quickshell/settings-jump-contract | 13 + tests/quickshell/settings-pages-contract | 4 +- 21 files changed, 1405 insertions(+), 453 deletions(-) diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index e79dfcc..120c807 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -294,14 +294,13 @@ Singleton { } ] }, - // Grouped with the workspaces rather than with focus, because a group is - // where a setting is found rather than what it is about: the slider that - // sets this is a schema-bound row on WorkspacesPage. The focus group - // routes to Notifications, which is where focusModes renders. + // Set by the duration chips on the Focus tab, which is also where + // focusModes renders and where the focus group routes -- one editor, + // one page, so search and the docs point at the only place it exists. { key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5, unit: "min", - group: "workspaces", + group: "focus", label: "Focus session length", detail: "How long a focus session runs before it ends itself" }, @@ -1490,6 +1489,20 @@ Singleton { label: "Visible banners", detail: "Maximum notification banners shown at once" }, + // The one hole anyone is allowed to punch in Do Not Disturb. Off by + // default, because Do Not Disturb that lets something through anyway is + // not the thing most people asked for -- and "critical" is the sender's + // word, not yours, so an application that calls everything critical + // would otherwise defeat the switch on its own say-so. Turned on, it + // shows a banner for critical notifications while a mode or a manual Do + // Not Disturb is silencing everything else; the per-application urgency + // override is how you decide which senders get to claim it. + { + key: "criticalBreaksThrough", type: "bool", def: false, + group: "notifications", + label: "Critical alerts break through", + detail: "Show critical notifications as banners even while Do Not Disturb is on" + }, // ── Sound ─────────────────────────────────────────────────────────── // The two audio preferences that are Panama's own. Everything else on @@ -1618,8 +1631,17 @@ Singleton { }, // ── Per-application notification rules ────────────────────────────── - // { "": { enabled } } -- lock-screen fields from older rules are - // dropped at normalization; hyprlock cannot render notifications. + // { "": { enabled, sound, display, urgency, lastSeenMs, name, + // icon } -- every field past `enabled` is optional, so rules written + // when this held only `enabled` still load. Lock-screen fields from + // older rules are dropped at normalization; hyprlock cannot render + // notifications. + // + // `name` and `icon` are a cache for the settings list, not the source: + // resolution stays live-first through DesktopEntries, and these only + // stand in for an application that is not installed (or not scanned + // yet). `lastSeenMs` is stamped on every notification and is what puts + // an application in the "Recent" section. // // Absent means "no rule", which is not the same as a rule that allows // everything: a new application must be able to notify without needing @@ -1630,7 +1652,7 @@ Singleton { key: "notificationAppRules", type: "json", def: ({}), group: "notifications", internal: true, label: "Application notification rules", - detail: "Per-application notification and lock-screen visibility preferences" + detail: "Per-application notification sound, banner, and urgency preferences" }, // ── Internal ──────────────────────────────────────────────────────── diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml index 26b86fd..5134c11 100644 --- a/config/dot/quickshell/config/Settings.qml +++ b/config/dot/quickshell/config/Settings.qml @@ -82,6 +82,9 @@ Singleton { readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit") readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts") + // The single exception to Do Not Disturb, read by the popup gate in + // services/Notifs.qml. Off means Do Not Disturb is absolute. + readonly property bool criticalBreaksThrough: DesktopPreferences.get("criticalBreaksThrough") // ── Sound ─────────────────────────────────────────────────────────────── // Over-amplification is the clamp ceiling for output volume: off means 1.0, diff --git a/config/dot/quickshell/modules/notifications/NotificationCard.qml b/config/dot/quickshell/modules/notifications/NotificationCard.qml index 966ed8d..8a0c1c7 100644 --- a/config/dot/quickshell/modules/notifications/NotificationCard.qml +++ b/config/dot/quickshell/modules/notifications/NotificationCard.qml @@ -56,6 +56,11 @@ Rectangle { // Critical notifications get a red edge rather than a color wash, so the // text contrast never changes. + // + // Read through Notifs.effectiveUrgency rather than off the notification, so + // an application the rules treat as critical is marked here too -- and one + // demoted to low is not. The bell, the popup timeout and the Do Not Disturb + // breakthrough all ask the same question the same way. Rectangle { anchors.left: parent.left anchors.top: parent.top @@ -65,7 +70,7 @@ Rectangle { radius: 1.5 border.width: 0 color: Theme.urgent - visible: root.notification.urgency === NotificationUrgency.Critical + visible: Notifs.effectiveUrgency(root.notification) === NotificationUrgency.Critical } readonly property var defaultAction: { diff --git a/config/dot/quickshell/modules/settings/GamingPage.qml b/config/dot/quickshell/modules/settings/GamingPage.qml index 32b92a4..15e2439 100644 --- a/config/dot/quickshell/modules/settings/GamingPage.qml +++ b/config/dot/quickshell/modules/settings/GamingPage.qml @@ -123,13 +123,13 @@ SettingsPage { detail: { const mode = FocusModes.modes.find(entry => entry.id === "gaming"); if (!mode) - return "Handled by a focus mode on Notifications & Focus."; + return "Handled by a focus mode on the Focus tab."; return mode.enabled === true ? "Handled by the Gaming focus mode, which is on." : "The Gaming focus mode is off, so notifications are not silenced."; } action: "Open Focus" - onTriggered: ShellState.settingsPage = "notifications" + onTriggered: ShellState.openSettings("focus") } ToggleRow { diff --git a/config/dot/quickshell/modules/settings/NotificationsPage.qml b/config/dot/quickshell/modules/settings/NotificationsPage.qml index 87665e2..07ef924 100644 --- a/config/dot/quickshell/modules/settings/NotificationsPage.qml +++ b/config/dot/quickshell/modules/settings/NotificationsPage.qml @@ -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() - } } } } diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 231be97..0d5263d 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -175,6 +175,7 @@ Rectangle { case "sound": return soundPage; case "gaming": return gamingPage; case "notifications": return notificationsPage; + case "focus": return focusPage; case "screen-intelligence": return screenIntelligencePage; case "shortcuts": return shortcutsPage; case "mouse": return mousePage; @@ -260,6 +261,7 @@ Rectangle { Component { id: soundPage; SoundPage {} } Component { id: gamingPage; GamingPage {} } Component { id: notificationsPage; NotificationsPage {} } + Component { id: focusPage; FocusPage {} } Component { id: screenIntelligencePage; ScreenIntelligencePage {} } Component { id: shortcutsPage; ShortcutsPage {} } Component { id: mousePage; MousePage {} } diff --git a/config/dot/quickshell/modules/settings/WorkspacesPage.qml b/config/dot/quickshell/modules/settings/WorkspacesPage.qml index 8053021..33743eb 100644 --- a/config/dot/quickshell/modules/settings/WorkspacesPage.qml +++ b/config/dot/quickshell/modules/settings/WorkspacesPage.qml @@ -30,12 +30,6 @@ SettingsPage { ToggleRow { setting: "windowSwallow"; divider: false } } - SettingsCard { - title: "Focus" - - SliderRow { setting: "focusDurationMinutes"; divider: false } - } - // Saved here, not created here. A layout is worth recording at the moment // you have it right, so saving is a launcher command; this is where the // ones you kept are reviewed and the ones you did not are removed. diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index df51a9a..bbdf93c 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -22,6 +22,10 @@ SyncPage 1.0 SyncPage.qml DisplaysPage 1.0 DisplaysPage.qml HomePage 1.0 HomePage.qml NotificationsPage 1.0 NotificationsPage.qml +NotificationAppRow 1.0 NotificationAppRow.qml +FocusPage 1.0 FocusPage.qml +FocusModeRow 1.0 FocusModeRow.qml +FocusAllowChips 1.0 FocusAllowChips.qml PasswordRow 1.0 PasswordRow.qml PrintersPage 1.0 PrintersPage.qml ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml diff --git a/config/dot/quickshell/services/FocusModes.qml b/config/dot/quickshell/services/FocusModes.qml index d082f3f..902db45 100644 --- a/config/dot/quickshell/services/FocusModes.qml +++ b/config/dot/quickshell/services/FocusModes.qml @@ -30,6 +30,16 @@ Singleton { } // Modes something could switch on without being asked. + // + // A mode whose only trigger is "manual" is deliberately not here, and that + // is its whole activation story: triggerActive() answers false for + // "manual", so such a mode is never `activeMode` and never silences + // anything by itself. Switching it on means exactly one thing -- its + // `enabled` flag is true -- and the timed session that people actually + // start by hand is FocusSession, which owns Do Not Disturb itself and does + // not read this list. That split is the single-owner rule from the + // contract, so a manual mode is a saved intent rather than a second + // silencer, and the editor says so rather than pretending otherwise. readonly property var automatic: root.modes.filter(mode => mode.enabled === true && (mode.triggers ?? []).some(trigger => trigger.kind !== "manual")) @@ -213,6 +223,130 @@ Singleton { mode.id === id ? Object.assign({}, mode, changes) : mode)); } + // ── Editing the list ──────────────────────────────────────────────────── + // + // The list is the priority order (first match wins), so creating appends + // and moving is a real edit rather than a view preference. Every function + // here answers false when it was asked about a mode that is not there, + // instead of silently writing the list back unchanged. + + function hasMode(id: string): bool { + return root.modes.some(mode => mode.id === id); + } + + // A readable id derived from the name, made unique by suffix. Ids are + // referenced by nothing outside this list -- GamingPage looks up "gaming" + // and that mode ships with the schema -- so this only has to stay stable + // for itself. + function uniqueId(name: string): string { + const base = String(name ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "mode"; + let candidate = base; + let suffix = 2; + while (root.hasMode(candidate)) { + candidate = base + "-" + suffix; + suffix++; + } + return candidate; + } + + // Returns the new mode's id, so the page can open the row it just made. + // A new mode is manual and silencing: it does nothing until somebody picks + // what turns it on, which is the safe direction for a thing whose job is + // to quiet the machine. + function createMode(name: string): string { + const label = String(name ?? "").trim() || "New mode"; + const id = root.uniqueId(label); + root.save(root.modes.concat([{ + id: id, + name: label, + enabled: true, + triggers: [{ kind: "manual" }], + silence: true, + keepAwake: false, + allow: [] + }])); + return id; + } + + // Deleting the mode in force needs no special handling: activeMode is a + // binding over this list, so it recomputes to null and the release path + // below puts Do Not Disturb and Caffeine back. + function removeMode(id: string): bool { + if (!root.hasMode(id)) + return false; + root.save(root.modes.filter(mode => mode.id !== id)); + return true; + } + + function renameMode(id: string, name: string): bool { + const label = String(name ?? "").trim(); + if (!label || !root.hasMode(id)) + return false; + root.update(id, { name: label }); + return true; + } + + // Order is priority, so this is what "make Gaming beat Sleep" is. A move + // off either end is a no-op rather than a wrap, because the buttons that + // call this are disabled at the ends and a keyboard repeat should stop + // there rather than teleport the row. + function moveMode(id: string, delta: int): bool { + const next = root.modes.slice(); + const from = next.findIndex(mode => mode.id === id); + const to = from + Math.round(delta); + if (from < 0 || to < 0 || to >= next.length || to === from) + return false; + next.splice(to, 0, next.splice(from, 1)[0]); + root.save(next); + return true; + } + + // One trigger of the given kind, seeded so a mode is never saved in a + // state that means nothing. Unknown kinds are refused rather than stored: + // triggerActive() would read them as off, which is a mode that silently + // never turns on. + function seedTrigger(kind: string, fields: var): var { + const extra = fields && typeof fields === "object" ? fields : {}; + switch (String(kind ?? "")) { + case "schedule": + return { + kind: "schedule", + start: String(extra.start ?? "22:00"), + end: String(extra.end ?? "07:00"), + days: Array.isArray(extra.days) ? extra.days.map(Number) : [0, 1, 2, 3, 4, 5, 6] + }; + case "workspace": + return { kind: "workspace", id: Number(extra.id ?? 1) }; + case "fullscreen": + // `monitor` is optional and empty means any display. It is only + // carried when handed in, so the editor's plain "a window is + // fullscreen" stays plain. + return extra.monitor === undefined + ? { kind: "fullscreen" } + : { kind: "fullscreen", monitor: String(extra.monitor) }; + case "game": + return { kind: "game" }; + case "manual": + return { kind: "manual" }; + default: + return null; + } + } + + // Replaces the mode's triggers with exactly one. Every shipped mode has a + // single trigger and the editor only edits `triggers[0]`, so a list + // hand-edited to hold several collapses to one the first time its kind is + // changed here. The alternative -- editing one of several through a UI + // that shows one -- is the more surprising of the two. + function setTriggerKind(id: string, kind: string, fields: var): bool { + const trigger = root.seedTrigger(kind, fields); + if (!trigger || !root.hasMode(id)) + return false; + root.update(id, { triggers: [trigger] }); + return true; + } + // ── applying ──────────────────────────────────────────────────────────── // // What was true before a mode took over, so it can be put back. Recorded at diff --git a/config/dot/quickshell/services/Notifs.qml b/config/dot/quickshell/services/Notifs.qml index 4e220c4..1012270 100644 --- a/config/dot/quickshell/services/Notifs.qml +++ b/config/dot/quickshell/services/Notifs.qml @@ -60,15 +60,26 @@ Singleton { readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules) + // Every application that has a rule, with everything the settings list + // needs to draw a row: the live-resolved name, the cached icon, when it + // last said something, and the rule itself. Sorted by name, because the + // page decides its own sections (recent / customized / all) from the + // fields rather than from this order. readonly property var applications: { // byId()/heuristicLookup() do not make a binding by themselves. This // read updates persisted app labels once DesktopEntries finishes scan. const entries = DesktopEntries.applications.values; const remembered = root.rememberedApplications; - return Object.keys(root.appRules).map(appId => ({ - id: appId, - name: root.applicationLabel(appId, entries, remembered) - })).sort((a, b) => a.name.localeCompare(b.name)); + return Object.keys(root.appRules).map(appId => { + const rule = root.appRule(appId); + return { + id: appId, + name: root.applicationLabel(appId, entries, remembered), + icon: root.applicationIcon(appId, rule.icon), + lastSeenMs: rule.lastSeenMs, + rule: rule + }; + }).sort((a, b) => a.name.localeCompare(b.name)); } // Arrival times, keyed by notification id — the protocol carries no @@ -86,8 +97,11 @@ Singleton { // Disturb is on (below). Critical urgency and an explicit expireTimeout // override policy; -1 ("server decides") falls back to it. 0 means "never // auto-expire" per spec. + // Reads the effective urgency, so an application told to be treated as + // critical also gets the critical banner duration rather than only the + // critical look. function notificationTimeoutMs(notification: var): int { - if (notification.urgency === NotificationUrgency.Critical) + if (root.effectiveUrgency(notification) === NotificationUrgency.Critical) return Settings.notificationTimeoutCriticalMs; if (notification.expireTimeout === 0) return 0; @@ -103,37 +117,102 @@ Singleton { return desktopEntry || String(notification.appName ?? "").trim() || "Notifications"; } - function applicationLabel(appId: string, entries: var, remembered: var): string { + // The installed application behind a rule key, or null. An appId is either + // a desktop entry id or the raw name an application announced itself with, + // so both spellings and the heuristic are tried. + function desktopEntryFor(appId: string): var { const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId; - const entry = DesktopEntries.byId(appId) + return DesktopEntries.byId(appId) || DesktopEntries.byId(desktopId) || DesktopEntries.heuristicLookup(appId) || DesktopEntries.heuristicLookup(desktopId); - return entry?.name || remembered[appId]?.name || appId; } + // Live first, cache second: an installed application is named by its + // desktop entry every time, and the name stored on the rule only stands in + // for one that is gone, sandboxed, or not scanned yet. The other way round + // would freeze a name at the moment it was first heard from. + function applicationLabel(appId: string, entries: var, remembered: var): string { + return root.desktopEntryFor(appId)?.name || remembered[appId]?.name || appId; + } + + // Same rule for the icon: the desktop entry wins, the cached icon (an + // entry icon, or the appIcon the notification carried) is the fallback. + function applicationIcon(appId: string, cached: string): string { + return root.desktopEntryFor(appId)?.icon || String(cached ?? ""); + } + + // The durable per-application rule. Everything past `enabled` is optional + // so rules written when this held only `enabled` still load, and anything + // unrecognized -- including the lock-screen pair described below -- is + // dropped rather than carried forward as if something read it. + // + // enabled off rejects the notification before anything else happens + // sound false silences the bell for this application only + // display "history" files it without a banner and without a bell + // urgency "low"/"critical" override what the application claims + // lastSeenMs when it last notified, stamped by rememberApplication + // name, icon cached labels for the settings list (see above: fallback) function normalizedAppRule(rule: var): var { const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {}; - // Only `enabled` -- see the lock-screen note below for why the rule - // model carries nothing else, and drops old fields on the way through. - return { enabled: source.enabled !== false }; + const display = String(source.display ?? ""); + const urgency = String(source.urgency ?? ""); + const lastSeen = Number(source.lastSeenMs); + return { + enabled: source.enabled !== false, + sound: source.sound !== false, + display: display === "history" ? "history" : "banners", + urgency: urgency === "low" || urgency === "critical" ? urgency : "auto", + lastSeenMs: isFinite(lastSeen) && lastSeen > 0 ? Math.round(lastSeen) : 0, + name: String(source.name ?? ""), + icon: String(source.icon ?? "") + }; } + // A rule with no defaults touched. Everything the page calls "customized" + // is a field that differs from this. + readonly property var defaultAppRule: root.normalizedAppRule({}) + function appRule(appId: string): var { return root.normalizedAppRule(root.appRules[appId]); } + // Has this application been given a rule that actually says something? The + // bookkeeping fields do not count -- an application is "customized" + // because of a decision somebody made about it, not because it notified. + function isCustomized(appId: string): bool { + const rule = root.appRule(appId); + return rule.enabled !== true || rule.sound !== true + || rule.display !== "banners" || rule.urgency !== "auto"; + } + function setAppRule(appId: string, patch: var): bool { if (!appId) return false; - const current = root.appRule(appId); + const changes = patch && typeof patch === "object" ? patch : {}; + const merged = Object.assign({}, root.appRule(appId)); + // Field by field rather than a blind merge, so a patch cannot smuggle + // a value of the wrong type (or an unknown key) into the stored rule. + if (changes.enabled !== undefined) + merged.enabled = changes.enabled === true; + if (changes.sound !== undefined) + merged.sound = changes.sound === true; + if (changes.display !== undefined) + merged.display = String(changes.display); + if (changes.urgency !== undefined) + merged.urgency = String(changes.urgency); + if (changes.lastSeenMs !== undefined) + merged.lastSeenMs = Number(changes.lastSeenMs); + if (changes.name !== undefined) + merged.name = String(changes.name); + if (changes.icon !== undefined) + merged.icon = String(changes.icon); + const next = {}; for (const knownAppId of Object.keys(root.appRules)) next[knownAppId] = root.appRule(knownAppId); - next[appId] = { - enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true - }; + next[appId] = root.normalizedAppRule(merged); if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next)) root.fallbackAppRules = {}; @@ -142,6 +221,49 @@ Singleton { return true; } + // Delete the rule outright, rather than resetting its fields: an + // application with no rule is treated as permissive, so forgetting one is + // undone by its next notification, which writes a fresh default rule. + // The session-only remembered name goes with it, otherwise the row would + // linger under a name nothing is keeping. + function forgetApp(appId: string): bool { + if (!appId || root.appRules[appId] === undefined) + return false; + + const next = {}; + for (const knownAppId of Object.keys(root.appRules)) { + if (knownAppId !== appId) + next[knownAppId] = root.appRule(knownAppId); + } + + const remembered = Object.assign({}, root.rememberedApplications); + delete remembered[appId]; + root.rememberedApplications = remembered; + + // The session fallback is a whole-map replacement, so a deletion only + // truly lands once the preference write succeeds -- which it does + // whenever the schema key exists, i.e. always outside the transitional + // build this fallback was written for. + if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next)) + root.fallbackAppRules = {}; + else + root.fallbackAppRules = next; + return true; + } + + // What this notification counts as after the application's override: the + // urgency the sender claimed unless a rule disagrees with it. Read by the + // bell, the banner timeout, the Do Not Disturb breakthrough gate, and the + // card's critical edge, so all four agree on one answer. + function effectiveUrgency(notification: var): var { + const rule = root.appRule(root.notificationAppId(notification)); + if (rule.urgency === "low") + return NotificationUrgency.Low; + if (rule.urgency === "critical") + return NotificationUrgency.Critical; + return notification.urgency; + } + function rememberApplication(notification: var): string { const appId = root.notificationAppId(notification); const next = Object.assign({}, root.rememberedApplications); @@ -150,8 +272,22 @@ Singleton { }; root.rememberedApplications = next; - if (root.appRules[appId] === undefined) - root.setAppRule(appId, {}); + // Stamped alongside the labels the settings list falls back to when + // the application is not installed here. Coalesced to one write per + // minute per app: every setAppRule bumps the preferences revision and + // restarts the settings.json write debounce, so stamping a chat + // burst's every message would churn the whole preference store for a + // timestamp nobody reads at that resolution. + const rule = root.appRule(appId); + const name = root.applicationLabel(appId, DesktopEntries.applications.values, next); + const icon = root.applicationIcon(appId, String(notification.appIcon ?? "")); + if (Date.now() - rule.lastSeenMs >= 60000 || rule.name !== name || rule.icon !== icon) { + root.setAppRule(appId, { + lastSeenMs: Date.now(), + name: name, + icon: icon + }); + } return appId; } @@ -211,7 +347,10 @@ Singleton { return; const appId = root.rememberApplication(notification); - if (!root.appRule(appId).enabled) + // Read once: the same rule decides rejection, the bell, and + // whether this is allowed to be a banner at all. + const rule = root.appRule(appId); + if (!rule.enabled) return; // Without this the object is destroyed the instant this returns. @@ -228,10 +367,27 @@ Singleton { root.unreadCount += 1; } + // "History only" is the quiet filing cabinet: it was added to + // history and counted as unread above, and that is all it gets -- + // no banner, and (never reaching playBell) no sound either. + const historyOnly = rule.display === "history"; + + // The one exception to Do Not Disturb that is not a focus mode's, + // and it is off unless somebody turned it on. It reads the + // effective urgency, so a per-application override decides who + // gets to claim "critical" rather than the sender alone. + const breaksThrough = Settings.criticalBreaksThrough + && root.effectiveUrgency(notification) === NotificationUrgency.Critical; + // An exception belongs to the focus mode that is in force. A Do Not // Disturb switched on by hand has no exceptions and stays absolute, // because FocusModes.allows is false whenever no mode is active. - if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) { + if (historyOnly) { + // Transient notifications are filed nowhere, so a hidden one + // still needs the release its popup timeout would have given. + if (notification.transient) + root.scheduleTransientExpiry(notification); + } else if (!root.doNotDisturb || FocusModes.allows(appId) || breaksThrough) { root.popups = [notification].concat(root.popups); root.playBell(notification); } else if (notification.transient) { @@ -260,9 +416,17 @@ Singleton { if (!SoundFeedback.eventSounds) return; + // The per-application sound switch. Narrower than turning the + // application off: its notifications still arrive and still show, they + // just stop making noise. + if (!root.appRule(root.notificationAppId(notification)).sound) + return; + // Low urgency is the "you did not need to know this" tier -- battery - // reaching full, a sync completing. It stays silent by design. - if (notification.urgency === NotificationUrgency.Low) + // reaching full, a sync completing. It stays silent by design, and it + // is the effective urgency, so "treat as low" is a way to keep an + // application audible in principle but quiet in practice. + if (root.effectiveUrgency(notification) === NotificationUrgency.Low) return; // The freedesktop sound hints. This is the fix for the double chime: diff --git a/config/dot/quickshell/services/SettingsRoutes.qml b/config/dot/quickshell/services/SettingsRoutes.qml index a6c5b55..f976bb2 100644 --- a/config/dot/quickshell/services/SettingsRoutes.qml +++ b/config/dot/quickshell/services/SettingsRoutes.qml @@ -24,8 +24,9 @@ Singleton { // A category with no tabs is a leaf itself. A category with tabs is // addressed by its first available tab; its own page id is accepted as an - // alias. Three category ids ("applications", "users", "privacy") double as - // the id of their first tab, which resolves to the same place either way. + // alias. Four category ids ("notifications", "applications", "users", + // "privacy") double as the id of their first tab, which resolves to the + // same place either way. readonly property var categories: [ { page: "home", label: "Home", icon: "\u{F02DC}", tabs: [ { page: "home", label: "Overview" }, @@ -42,7 +43,10 @@ Singleton { ] }, { page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] }, { page: "sound", label: "Sound", icon: "\u{F057E}", tabs: [] }, - { page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}", tabs: [] }, + { page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}", tabs: [ + { page: "notifications", label: "Notifications" }, + { page: "focus", label: "Focus" } + ] }, { page: "input", label: "Input", icon: "\u{F030C}", tabs: [ { page: "shortcuts", label: "Keyboard" }, { page: "mouse", label: "Mouse & Touchpad" }, diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index fde5f78..c82e37d 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -42,7 +42,9 @@ Singleton { "wallpaper": "appearance", "lockAppearance": "appearance", "dock": "dock", - "focus": "notifications", + // Focus modes moved off the Notifications page onto their own tab, so + // the group that owns them routes there now. + "focus": "focus", "display": "displays", "nightLight": "displays", "idle": "power", @@ -158,6 +160,14 @@ Singleton { { label: "Pinned applications", detail: "Reorder the dock by dragging, here or on the dock itself", page: "dock" }, { label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" }, { label: "Control Center sections", detail: "Choose what the panel offers", page: "control-center" }, + { label: "Do Not Disturb", detail: "Hold banners back until you turn it off", page: "notifications" }, + { label: "Quiet hours", detail: "The schedule the Sleep focus mode keeps", page: "notifications" }, + { label: "Critical alerts break through", detail: "Let urgent notifications past Do Not Disturb", page: "notifications" }, + { label: "Application notification rules", detail: "Which applications may notify you, and how", page: "notifications" }, + { label: "Forget an app's notifications", detail: "Remove its rule; it returns on its next notification", page: "notifications" }, + { label: "Per-app notification sound", detail: "Turn the chime off for one application", page: "notifications" }, + { label: "Banners or history", detail: "Send one application straight to history without a popup", page: "notifications" }, + { label: "Focus session duration", detail: "How long a focus session runs before it ends itself", page: "focus" }, { label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" }, { label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" }, { label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" }, diff --git a/config/local/share/vicinae/scripts/settings-notifications b/config/local/share/vicinae/scripts/settings-notifications index 0314c41..236efff 100755 --- a/config/local/share/vicinae/scripts/settings-notifications +++ b/config/local/share/vicinae/scripts/settings-notifications @@ -1,10 +1,10 @@ #!/usr/bin/env bash # Generated by scripts/panama-settings-commands -- do not edit by hand. # @vicinae.schemaVersion 1 -# @vicinae.title Settings: Notifications & Focus +# @vicinae.title Settings: Notifications # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg -# @vicinae.description Open Notifications & Focus in Settings. -# @vicinae.keywords ["settings", "focus modes", "notification duration", "critical notification duration", "notification history", "visible banners"] +# @vicinae.description Open Notifications in Settings. +# @vicinae.keywords ["settings", "notification duration", "critical notification duration", "notification history", "visible banners", "critical alerts break through", "do not disturb", "quiet hours", "application notification rules", "forget an app's notifications", "per-app notification sound", "banners or history"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page notifications diff --git a/config/local/share/vicinae/scripts/settings-workspaces b/config/local/share/vicinae/scripts/settings-workspaces index 71aadd2..05430bf 100755 --- a/config/local/share/vicinae/scripts/settings-workspaces +++ b/config/local/share/vicinae/scripts/settings-workspaces @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Workspaces in Settings. -# @vicinae.keywords ["settings", "focus session length", "switch back and forth", "wrap around at the ends", "let applications take focus", "hide the terminal that launched a window", "pointer changes active display"] +# @vicinae.keywords ["settings", "switch back and forth", "wrap around at the ends", "let applications take focus", "hide the terminal that launched a window", "pointer changes active display"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page workspaces diff --git a/docs/settings.md b/docs/settings.md index b8de58e..5da4ddb 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,7 +4,7 @@ Do not edit this file. Run `quickshell/scripts/panama-settings-docs` after changing the schema; a contract fails when this copy is stale. -164 settings across 35 groups. 70 of them are applied to the compositor and confirmed by reading the value back. +165 settings across 35 groups. 70 of them are applied to the compositor and confirmed by reading the value back. ## accessibility @@ -149,11 +149,12 @@ Found on **Appearance**. ## focus -Found on **Notifications & Focus**. +Found on **Notifications & Focus › Focus**. | Setting | Default | What it does | |---|---|---| | **Focus modes**
`focusModes` | — | What quiets this machine, and what turns it on | +| **Focus session length**
`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5–180. | ## gaming @@ -258,7 +259,7 @@ Found on **Shell › Tiling**. ## notifications -Found on **Notifications & Focus**. +Found on **Notifications & Focus › Notifications**. | Setting | Default | What it does | |---|---|---| @@ -266,6 +267,7 @@ Found on **Notifications & Focus**. | **Critical notification duration**
`notificationTimeoutCriticalMs` | 0 ms | Zero keeps critical notification banners visible until dismissed. Range 0–60000. | | **Notification history**
`notificationHistoryLimit` | 100 | Maximum notifications retained in the notification center. Range 10–500. | | **Visible banners**
`maxVisibleToasts` | 4 | Maximum notification banners shown at once. Range 1–8. | +| **Critical alerts break through**
`criticalBreaksThrough` | false | Show critical notifications as banners even while Do Not Disturb is on | ## pointer @@ -408,7 +410,6 @@ Found on **Shell › Workspaces**. | Setting | Default | What it does | |---|---|---| -| **Focus session length**
`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5–180. | | **Switch back and forth**
`workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one | | **Wrap around at the ends**
`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first | | **Let applications take focus**
`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted | diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md index 285b1d8..a710b51 100644 --- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md +++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md @@ -358,3 +358,104 @@ trap, sound-cards' jq context rebind). displays-contract and switcher-contract — the locked-session holdovers — both pass unlocked. Later phases append new deferred contracts below as before; this line is the baseline they diverge from. + +## Phase 7 (Notifications & Focus) — append below + +Spec: `2026-08-24-notifications-focus-redesign.md`. Notifications & Focus became +two tabs. Per-application rules grew from one bool to seven fields with a real +editor behind them, Do Not Disturb gained a second exception, and focus modes +got create/rename/delete/reorder plus trigger-kind editing on a page of their +own. + +**Nothing in this wave was run.** Three agents were editing the tree +concurrently, and the one runtime harness here (`notification-app-rules-contract`) +boots a real Quickshell against a private D-Bus session. What *was* verified is +listed as static below: `bash -n` on every changed contract, and — for the two +contracts whose assertions are computed — the extracted blocks replayed in +`bun` and `python3` against the landed services, so the expected values in the +assertions are the values the shipped code actually produces. + +### New contracts (0) + +None. The redesign added surface to services and pages that already had +contracts, so the README count line stays at **169** and `setup/readme-contract` +needs nothing. + +### Updated contracts (4) + +| Contract | What it now pins | Verified | +|---|---|---| +| `quickshell/notification-app-rules-contract` | The rule shape, as two properties rather than a literal: every field past `enabled` is **optional** (an old `{enabled}`-only blob reads back with `sound`/`display`/`urgency`/`lastSeenMs`/`name`/`icon` at their defaults) and the field set is **closed** (unknown keys, the stale lock-screen pair, and out-of-vocabulary `display`/`urgency` values are all dropped; a non-numeric or negative `lastSeenMs` reads as 0). Plus: `forgetApp` deleting the key rather than writing an all-defaults rule; `rememberApplication` stamping `lastSeenMs`/`name`/`icon` from the clock; `effectiveUrgency` and all four of its consumers (`playBell`'s low check, `notificationTimeoutMs`'s critical duration, the breakthrough gate, `NotificationCard`'s critical edge); the per-app `sound` switch in `playBell`; `display: "history"` reaching history and unread but not the popup list or the bell; and the popup gate literal `!root.doNotDisturb \|\| FocusModes.allows(appId) \|\| breaksThrough` with `breaksThrough` being `Settings.criticalBreaksThrough` ANDed with effective-critical. The schema key and its `Settings` reader are pinned too. Page assertions now read the page **and** the components it delegates rows to (`NotificationAppRow.qml`), so pulling the expanded body into a component does not read as the feature being deleted. Runtime: four new fixtures — `historyOnly`, `forgetting`, `urgency`, `breakthrough` — and the two existing jq literals became field-by-field predicates, because `lastSeenMs` is a wall clock that cannot be written down. | **The whole static half was run** against the landed tree and prints PASS. The runtime half is **deferred**. | +| `quickshell/focus-modes-contract` | Every existing pin is untouched and still passes: conditions-not-alarms, the midnight-crossing / malformed / zero-length schedule arithmetic, single DND ownership, the gaming hook reporting rather than silencing, the retired `gamingSilenceNotifications` key staying out of the schema, and the exception list being both consulted and editable. New: `createMode`/`removeMode`/`renameMode`/`moveMode`/`setTriggerKind`/`seedTrigger`/`uniqueId`/`hasMode` by name; a `bun` replay of `seedTrigger` (all five kinds and their seeds, handed-in fields kept, unknown kinds refused with `null` rather than stored) and of `uniqueId` (slugging, suffixing past a taken id, and the `"mode"` fallback); the "order is priority" claim having to appear on the page; and the manual-mode semantics being *preserved* rather than invented — `automatic` still filters manual-only modes out and the fall-through still carries its note. The editable-list needle moved from `NotificationsPage.qml` to the Focus surface (`FocusPage.qml` + `Focus*.qml`), because the chips live in `FocusModeRow.qml`. | **Statically verified end to end**: every grep re-run against the landed files, the schedule arithmetic replayed in `python3`, and the new API block replayed in `bun` — all green. | +| `quickshell/settings-pages-contract` | `Focus` added to the component list (root type `SettingsPage`, no copied Flickable scaffold) and `focus` to the runtime page-routing sweep. The four Notifications sliders and the `zeroLabel: "Never"` rule are unchanged and still hit. | **Static half verified**: `FocusPage.qml`'s root type and the four slider regexes replayed in `python3`. The routing sweep is **deferred** — it starts an isolated shell. | +| `quickshell/settings-jump-contract` | GamingPage's "Open Focus" now has to go through `ShellState.openSettings("focus")`, and assigning `ShellState.settingsPage` by hand is banned there — the old code did exactly that, which skipped `SettingsRoutes.resolve()` and so skipped this contract's own guard entirely. `NotificationCard`'s jump stays `"notifications"` and is unchanged. | **Statically verified** against the landed `GamingPage.qml` and `NotificationCard.qml`. | + +### Contracts deliberately left alone + +- `quickshell/search-routing-contract` — needed no edit. It derives page files + from `SettingsShell`'s switch, so `focus` resolved to `FocusPage.qml` the + moment B landed the case and the Component. Replayed by hand against the + landed tree: **144 routed settings, 0 violations** with the focus group + moved. +- `quickshell/settings-window-contract` — enumerates no tabs. It routes + `displays` and the retired `desktop` id only. +- `quickshell/settings-ownership-contract` — its duplicate-row scan and its + `groupPages` parse were replayed against the edited `SettingsSearch.qml` and + still pass. +- `setup/readme-contract` — no contract file added or removed; `find` still + counts 169 and the README still claims 169. + +### Docs updated in the same wave + +- `services/SettingsSearch.qml` — `groupPages` `"focus"` moved from + `"notifications"` to `"focus"`, plus eight hand-written entries: Do Not + Disturb, Quiet hours, Critical alerts break through, Application + notification rules, Forget an app's notifications, Per-app notification + sound, and Banners or history (all → `notifications`), and Focus session + duration (→ `focus`). Checked against `settings-search-contract`'s fixed + query list: none of the new labels or details contains any of its queried + substrings, so no existing top result moves. + +### Still open before the run + +- **A live bug this wave removed, worth knowing about.** + `notification-app-rules-contract` used to `perl`-graft a temporary + `notificationAppRules` schema key into its copy of `PreferenceSchema.qml`, + from the era before that key shipped. The key ships now, and the graft's + anchor comment still exists — so the contract was defining it **twice** in + the copied schema on every run. The graft is gone, replaced by an assertion + that both `notificationAppRules` and `criticalBreaksThrough` are present. +- `NotificationAppRulesHarness.qml`'s fake notification now spells out + `urgency`, `expireTimeout` and `hints`. It previously left all three + undefined, which meant the timeout path and the urgency path were exercised + in their undefined branch rather than their ordinary one. If a fixture + behaves differently than expected on the first run, that change is the first + place to look. +- The `breakthrough` fixture drives `Settings.criticalBreaksThrough` by writing + the preference and reading the binding back in the same JS call. That is + synchronous through `DesktopPreferences.set` (it reassigns `values` and bumps + `revision` before returning), but it has not been observed. If the fixture + reports `through: 0`, suspect binding timing before suspecting the gate. +- **`focusDurationMinutes` is now editable on two pages**: a `SliderRow` on + `WorkspacesPage.qml` and segmented chips on `FocusPage.qml`. No contract + catches it — `settings-ownership-contract` only scans the five schema-bound + row types and the chips are a `SegmentedRow` reading `DesktopPreferences` + directly, and `search-routing-contract` only sees `setting:` rows — but it is + a real ownership violation by `modules/settings/README.md`'s own rule. Decide + before the run: either the Workspaces row goes, or the key's `group` moves to + `focus` and the mirror gets named in `settings-ownership-contract`. +- **A stale comment in `config/PreferenceSchema.qml`** above + `focusDurationMinutes` still says "The focus group routes to Notifications, + which is where focusModes renders." It routes to `focus` now. Left for the + schema's owner rather than edited across agent lines. +- Run order for the sweep: `focus-modes-contract` first (pure static, no + compositor and no shell), then `settings-jump-contract` and + `search-routing-contract` (also static), then + `notification-app-rules-contract` (private D-Bus, isolated shell), and + `settings-pages-contract` last — it starts an isolated Quickshell beside the + live one and its own cleanup is what protects the running session. +- Nothing here plays a sound on purpose, but every delivery fixture reaches + `playBell`, which shells out through `SoundFeedback.playCommand`. That was + already true of the existing `exercise` fixture; the new fixtures add four + more chances for it. Keep the harness free of anything that turns the volume + up. diff --git a/tests/quickshell/NotificationAppRulesHarness.qml b/tests/quickshell/NotificationAppRulesHarness.qml index 5eb692b..c14550f 100644 --- a/tests/quickshell/NotificationAppRulesHarness.qml +++ b/tests/quickshell/NotificationAppRulesHarness.qml @@ -1,5 +1,6 @@ import Quickshell import Quickshell.Io +import Quickshell.Services.Notifications import QtQuick import qs.config @@ -8,6 +9,10 @@ import qs.services ShellRoot { id: root + // A stand-in for a served notification. `urgency` and `expireTimeout` are + // spelled out rather than left undefined: the effective-urgency override + // and the banner-duration choice both read them, so a fixture that omits + // them would be testing the undefined path instead of the ordinary one. function notification(idValue: int, desktopEntryValue: string, appNameValue: string): var { const closeHandlers = []; return { @@ -15,6 +20,9 @@ ShellRoot { desktopEntry: desktopEntryValue, appName: appNameValue, appIcon: "", + urgency: NotificationUrgency.Normal, + expireTimeout: -1, + hints: ({}), transient: false, lastGeneration: false, tracked: false, @@ -42,6 +50,24 @@ ShellRoot { Notifs.fallbackAppRules = {}; Notifs.rememberedApplications = {}; DesktopPreferences.set("notificationAppRules", {}); + DesktopPreferences.set("criticalBreaksThrough", false); + } + + // One notification through the whole delivery path, reported as the four + // counts that distinguish every outcome from every other: muted, filed + // quietly, held by Do Not Disturb, or shown. + function deliver(n: var): var { + Notifs.handleNotification(n); + return { + tracked: n.tracked, + history: Notifs.history.length, + popups: Notifs.popups.length, + unread: Notifs.unreadCount + }; + } + + function ruleKeys(): var { + return Object.keys(DesktopPreferences.get("notificationAppRules")); } IpcHandler { @@ -108,5 +134,115 @@ ShellRoot { function applications(): string { return JSON.stringify(Notifs.applications); } + + // display: "history" is not muting. It has to reach history and the + // unread count and stop short of the banner, which is the one + // combination of the four counts that says so. + function historyOnly(): string { + root.reset(); + const seed = root.notification(10, "org.history.App.desktop", "History App"); + Notifs.handleNotification(seed); + const appId = Notifs.notificationAppId(seed); + + root.resetNotifications(); + Notifs.setAppRule(appId, { display: "history" }); + return JSON.stringify(root.deliver( + root.notification(11, "org.history.App.desktop", "History App"))); + } + + // Forgetting removes the key rather than resetting its fields, so the + // next notification writes a fresh default rule and the row returns. + function forgetting(): string { + root.reset(); + const seed = root.notification(12, "org.forget.App.desktop", "Forget App"); + Notifs.handleNotification(seed); + const appId = Notifs.notificationAppId(seed); + + Notifs.setAppRule(appId, { sound: false, enabled: false }); + const before = root.ruleKeys().indexOf(appId) >= 0; + Notifs.forgetApp(appId); + const after = root.ruleKeys().indexOf(appId) >= 0; + // Forgetting something with no rule is a no-op, not a write. + const forgotUnknown = Notifs.forgetApp("org.never.Seen.desktop"); + + root.resetNotifications(); + Notifs.handleNotification( + root.notification(13, "org.forget.App.desktop", "Forget App")); + + return JSON.stringify({ + before: before, + after: after, + returned: root.ruleKeys().indexOf(appId) >= 0, + soundAfterReturn: Notifs.appRule(appId).sound, + forgotUnknown: forgotUnknown + }); + } + + // The override is one answer shared by the bell, the banner duration, + // the breakthrough gate and the card. Only the duration is observable + // from here, and it is the one that would silently keep the old value. + function urgency(): string { + root.reset(); + const n = root.notification(14, "org.urgent.App.desktop", "Urgent App"); + Notifs.handleNotification(n); + const appId = Notifs.notificationAppId(n); + + const claimed = Notifs.effectiveUrgency(n) === NotificationUrgency.Normal; + const normalTimeout = Notifs.notificationTimeoutMs(n) === Settings.notificationTimeoutMs; + + Notifs.setAppRule(appId, { urgency: "critical" }); + const escalated = Notifs.effectiveUrgency(n) === NotificationUrgency.Critical; + const escalatedTimeout = + Notifs.notificationTimeoutMs(n) === Settings.notificationTimeoutCriticalMs; + + Notifs.setAppRule(appId, { urgency: "low" }); + const lowered = Notifs.effectiveUrgency(n) === NotificationUrgency.Low; + + return JSON.stringify({ + claimed: claimed, + escalated: escalated, + lowered: lowered, + normalTimeout: normalTimeout, + escalatedTimeout: escalatedTimeout + }); + } + + // Do Not Disturb with its second exception. Off, everything is held; + // on, exactly the effective-critical notifications get through -- and + // an application told to be treated as critical is one of them. + function breakthrough(): string { + root.reset(); + const seed = root.notification(15, "org.critical.App.desktop", "Critical App"); + Notifs.handleNotification(seed); + const appId = Notifs.notificationAppId(seed); + + function attempt(idValue, claimedUrgency) { + root.resetNotifications(); + Notifs.doNotDisturb = true; + const n = root.notification(idValue, "org.critical.App.desktop", "Critical App"); + n.urgency = claimedUrgency; + Notifs.handleNotification(n); + return Notifs.popups.length; + } + + DesktopPreferences.set("criticalBreaksThrough", false); + const held = attempt(16, NotificationUrgency.Critical); + + DesktopPreferences.set("criticalBreaksThrough", true); + const through = attempt(17, NotificationUrgency.Critical); + const ordinary = attempt(18, NotificationUrgency.Normal); + + Notifs.setAppRule(appId, { urgency: "critical" }); + const escalated = attempt(19, NotificationUrgency.Normal); + + DesktopPreferences.set("criticalBreaksThrough", false); + Notifs.doNotDisturb = false; + return JSON.stringify({ + held: held, + through: through, + ordinary: ordinary, + escalated: escalated + }); + } } } diff --git a/tests/quickshell/focus-modes-contract b/tests/quickshell/focus-modes-contract index c2440b0..36ad51a 100755 --- a/tests/quickshell/focus-modes-contract +++ b/tests/quickshell/focus-modes-contract @@ -31,13 +31,14 @@ service="$repo_dir/config/dot/quickshell/services/FocusModes.qml" schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" hook="$repo_dir/config/dot/quickshell/scripts/panama-gaming" gaming_page="$repo_dir/config/dot/quickshell/modules/settings/GamingPage.qml" +focus_page="$repo_dir/config/dot/quickshell/modules/settings/FocusPage.qml" fail() { printf 'focus modes contract: %s\n' "$1" >&2 exit 1 } -for path in "$service" "$schema" "$hook" "$gaming_page"; do +for path in "$service" "$schema" "$hook" "$gaming_page" "$focus_page"; do [[ -r "$path" ]] || fail "missing $path" done @@ -158,7 +159,134 @@ grep -q 'function allows' "$service" \ grep -q 'if (!mode || mode.silence !== true)' "$service" \ || fail 'exceptions are not scoped to an active silencing mode, so a manual Do Not Disturb could leak' -grep -q 'function toggleAllowed' "$page" \ +# The editor moved from the Notifications page to the Focus tab when the +# category split in two, and the accordion row and its chips became their own +# components. What must hold is unchanged: somewhere a person can reach, the +# list the gate above consults can be edited. Asserted over the whole Focus +# surface rather than the page file, so pulling a row into a component does +# not read as the feature being deleted. +focus_surface=("$focus_page" "$repo_dir"/config/dot/quickshell/modules/settings/Focus*.qml) + +focus_surface_has() { + grep -qF "$1" "${focus_surface[@]}" +} + +focus_surface_has 'FocusAllowChips' \ + || fail 'nothing on the Focus page renders the exception chips, so the list is unreachable' +focus_surface_has 'FocusModes.update(' \ + || fail 'the Focus page never writes a mode back, so nothing it shows can be edited' +focus_surface_has 'allow:' \ || fail 'the exception list cannot be edited, so the summary could claim something unreachable' +# The chips are only honest if removing one writes the shorter list back. +focus_surface_has 'toggled(' \ + || fail 'the chips report nothing, so removing an application from a mode would change only the chip' + +# ── 7. A mode you can make, name, order and retrigger ─────────────────────── +# +# The five modes used to be whatever the schema shipped: the page could toggle +# them and edit a schedule, and nothing else. Everything below is new surface +# on a service whose entire job is to silence a machine, so each entry point is +# pinned by name and the two that compute something are replayed. + +for required in createMode removeMode renameMode moveMode setTriggerKind seedTrigger uniqueId hasMode; do + grep -q "function $required" "$service" \ + || fail "there is no $required(), so the editor would be calling something that does not exist" +done + +# Manual modes did not gain an activation path when the editor did. A manual +# mode is never `activeMode`, because triggerActive() answers false for it and +# `automatic` filters it out -- FocusSession owns the by-hand session, which is +# rule 4 above. An editor that quietly made "manual" mean "on" would be a +# second owner for Do Not Disturb wearing a new name. +grep -q 'trigger.kind !== "manual"' "$service" \ + || fail 'manual modes are no longer excluded from the automatic list, so switching one on would silence the machine behind FocusSession' +grep -q '"manual" included: FocusSession owns those' "$service" \ + || fail 'the manual fall-through lost the note explaining why it is not an activation path' + +# Order is priority -- activeMode takes the first matching mode -- so reordering +# is a behaviour change and the page has to say so rather than looking like a +# cosmetic sort. +grep -qi 'priority' "$focus_page" \ + || fail 'the Focus page never says the order is the priority, so dragging a row reads as decoration' + +PANAMA_FOCUS_SERVICE="$service" bun -e ' +const source = await Bun.file(process.env.PANAMA_FOCUS_SERVICE).text(); + +function fail(message) { + console.error(`focus modes contract: ${message}`); + process.exit(1); +} + +// Same extraction the schedule arithmetic uses above, so these replay the +// shipped function rather than a copy that can drift. +function functionBody(name) { + const start = source.indexOf(`function ${name}(`); + if (start === -1) + fail(`missing ${name}()`); + const open = source.indexOf("{", start); + let depth = 0; + for (let index = open; index < source.length; index++) { + if (source[index] === "{") depth++; + if (source[index] === "}" && --depth === 0) + return source.slice(open + 1, index); + } + fail(`${name}() is unterminated`); +} + +const seedTrigger = Function("kind", "fields", functionBody("seedTrigger")); +const uniqueIdBody = Function("root", "name", functionBody("uniqueId")); +const stubRoot = modes => ({ + modes: modes, + hasMode: id => modes.some(mode => mode.id === id) +}); + +function same(actual, expected, why) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) + fail(`${why}: got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`); +} + +// A trigger is seeded so that a mode is never saved meaning nothing. A +// schedule with no days, or a workspace trigger with no workspace, is a mode +// that silently never turns on -- the failure that is hardest to notice, +// because nothing happens. +same(seedTrigger("schedule", undefined), + { kind: "schedule", start: "22:00", end: "07:00", days: [0, 1, 2, 3, 4, 5, 6] }, + "a fresh schedule trigger"); +same(seedTrigger("schedule", { start: "23:15", days: [5] }), + { kind: "schedule", start: "23:15", end: "07:00", days: [5] }, + "a schedule keeps what it was handed and defaults the rest"); +same(seedTrigger("workspace", undefined), { kind: "workspace", id: 1 }, + "a fresh workspace trigger"); +same(seedTrigger("workspace", { id: "3" }), { kind: "workspace", id: 3 }, + "a workspace id arriving as text"); +same(seedTrigger("fullscreen", undefined), { kind: "fullscreen" }, + "a fullscreen trigger carries no fields"); +same(seedTrigger("game", undefined), { kind: "game" }, "a game trigger carries no fields"); +same(seedTrigger("manual", undefined), { kind: "manual" }, "a manual trigger carries no fields"); + +// An unknown kind is refused rather than stored. triggerActive() reads an +// unknown kind as off, so storing one would be a mode that can never turn on. +for (const kind of ["", "sometimes", undefined, null]) { + if (seedTrigger(kind, undefined) !== null) + fail(`an unknown trigger kind ${JSON.stringify(kind)} was seeded instead of refused`); +} + +// Ids are derived from the name and made unique by suffix. A duplicate id +// would make removeMode and update() act on whichever came first. +const existing = [{ id: "deep-work" }, { id: "deep-work-2" }, { id: "sleep" }]; +for (const [name, expected, why] of [ + ["Reading", "reading", "a plain name"], + [" Sleep ", "sleep-2", "a name whose id is taken"], + ["Deep work", "deep-work-3", "a name whose first two ids are taken"], + ["!!!", "mode", "a name with nothing usable in it"], + ["", "mode", "an empty name"] +]) { + const actual = uniqueIdBody(stubRoot(existing), name); + if (actual !== expected) + fail(`${why}: uniqueId gave ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`); +} + +console.log("focus modes API: ok"); +' >/dev/null || fail 'the mode editor API does not behave as the Focus page assumes' printf 'focus modes contract: ok\n' diff --git a/tests/quickshell/notification-app-rules-contract b/tests/quickshell/notification-app-rules-contract index 0e6f5df..e003c64 100755 --- a/tests/quickshell/notification-app-rules-contract +++ b/tests/quickshell/notification-app-rules-contract @@ -18,9 +18,31 @@ fail() { [[ -f "$harness_fixture" ]] || fail 'runtime harness fixture is missing' [[ -f "$desktop_entry_fixture" ]] || fail 'runtime desktop entry fixture is missing' -SERVICE_PATH="$service" PAGE_PATH="$page" bun -e ' +card="$repo_dir/config/dot/quickshell/modules/notifications/NotificationCard.qml" +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" +settings="$repo_dir/config/dot/quickshell/config/Settings.qml" + +[[ -f "$card" ]] || fail 'notification card is missing' +[[ -f "$schema" ]] || fail 'preference schema is missing' +[[ -f "$settings" ]] || fail 'settings singleton is missing' + +# The expanded row body lives in its own component now. It is part of the same +# surface as the page, so the per-application assertions read both rather than +# only the file that happens to hold the card today -- otherwise pulling a row +# into a component reads as the feature being deleted. Scoped to the +# Notification* components so a check cannot be satisfied by some unrelated +# page that also happens to mention Notifs. +page_family="$(find "$repo_dir/config/dot/quickshell/modules/settings" \ + -maxdepth 1 -name 'Notification*.qml' -not -name 'NotificationsPage.qml' \ + | sort | tr '\n' ':')" + +SERVICE_PATH="$service" PAGE_PATH="$page" CARD_PATH="$card" \ +SCHEMA_PATH="$schema" SETTINGS_PATH="$settings" PAGE_FAMILY="$page_family" bun -e ' const source = await Bun.file(process.env.SERVICE_PATH).text(); const page = await Bun.file(process.env.PAGE_PATH).text(); +const card = await Bun.file(process.env.CARD_PATH).text(); +const schema = await Bun.file(process.env.SCHEMA_PATH).text(); +const settings = await Bun.file(process.env.SETTINGS_PATH).text(); function fail(message) { console.error(`notification application rules contract: ${message}`); @@ -55,25 +77,180 @@ for (const fixture of identityFixtures) { fail(`stable app identity expected ${fixture.expected}, got ${actual}`); } -const defaultRule = normalizedAppRule({}); -if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true })) - fail(`missing rule fields did not default safely: ${JSON.stringify(defaultRule)}`); +// ── The rule shape ────────────────────────────────────────────────────────── +// +// The rule grew from a single `enabled` bool to seven fields. Two properties +// of that growth are what this checks, because both are invisible from the +// code and both corrupt somebody stored preferences if they break: +// +// 1. Every new field is OPTIONAL. A rule written by the version that only +// knew `enabled` is still a valid rule, and reads back with the new +// fields at their defaults rather than as undefined. +// 2. The field set is CLOSED. Anything unrecognized is dropped on the way +// through -- including the two lock-screen keys that once shipped -- so +// nothing can appear to be stored policy that nothing reads. +// +// The enum fields are checked for the same closure: a stored typo reads back +// as the default, never as a third display mode or a fourth urgency. +const ruleDefaults = { + enabled: true, + sound: true, + display: "banners", + urgency: "auto", + lastSeenMs: 0, + name: "", + icon: "" +}; -// Old stored rules may still carry lock-screen fields from the era when the -// page offered switches for them; normalization must drop dead fields, not -// carry them forward as if something read them. -const explicitRule = normalizedAppRule({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false }); -if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false })) - fail(`stale lock-screen fields were not dropped: ${JSON.stringify(explicitRule)}`); +function sameRule(stored, expected, why) { + const actual = normalizedAppRule(stored); + const actualKeys = Object.keys(actual).sort().join(","); + const expectedKeys = Object.keys(expected).sort().join(","); + if (actualKeys !== expectedKeys) + fail(`${why}: rule fields are [${actualKeys}], expected [${expectedKeys}]`); + for (const key of Object.keys(expected)) { + if (actual[key] !== expected[key]) + fail(`${why}: ${key} read back as ${JSON.stringify(actual[key])}, expected ${JSON.stringify(expected[key])}`); + } +} -for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification"]) { +sameRule({}, ruleDefaults, "an empty rule"); +sameRule(undefined, ruleDefaults, "a missing rule"); +sameRule(null, ruleDefaults, "a null rule"); +sameRule([], ruleDefaults, "a rule stored as an array"); + +// The back-compat case: exactly what the previous version wrote. +sameRule({ enabled: true }, ruleDefaults, "an old enabled-only rule"); +sameRule({ enabled: false }, Object.assign({}, ruleDefaults, { enabled: false }), + "an old muted rule"); + +// Stale lock-screen fields, plus a key from no version at all. +sameRule( + { enabled: false, showOnLockScreen: false, showContentOnLockScreen: false, invented: 7 }, + Object.assign({}, ruleDefaults, { enabled: false }), + "stale and unknown fields" +); + +// A fully specified rule round-trips unchanged. +const fullRule = { + enabled: false, sound: false, display: "history", urgency: "critical", + lastSeenMs: 1700000000000, name: "Signal", icon: "signal-desktop" +}; +sameRule(fullRule, fullRule, "a fully specified rule"); +sameRule({ urgency: "low" }, Object.assign({}, ruleDefaults, { urgency: "low" }), + "a low urgency override"); + +// Closed vocabularies. A value outside them is a stored typo, not a mode. +sameRule({ display: "shouty" }, ruleDefaults, "an unknown display mode"); +sameRule({ urgency: "urgent" }, ruleDefaults, "an unknown urgency override"); +sameRule({ display: "banners", urgency: "auto" }, ruleDefaults, "the defaults spelled out"); + +// lastSeenMs feeds relative-time rendering, so a non-number or a negative has +// to read as "never seen" rather than as a date in 1970 or in the future. +for (const bad of ["nope", "", null, NaN, -1, undefined]) { + sameRule({ lastSeenMs: bad }, ruleDefaults, `lastSeenMs stored as ${JSON.stringify(bad)}`); +} + +for (const required of [ + "rememberApplication", "appRule", "setAppRule", "handleNotification", + // New in the redesign: deleting a rule outright, and the one answer the + // bell, the timeout, the breakthrough gate and the card all read. + "forgetApp", "effectiveUrgency" +]) { functionBody(required); } -const handler = source.indexOf("function handleNotification(notification: var)"); -const tracked = source.indexOf("notification.tracked = true", handler); -const muted = source.indexOf("!root.appRule(appId).enabled", handler); -if (handler === -1 || tracked === -1 || muted === -1 || muted > tracked) +// Forgetting an application must DELETE its rule, not write a rule that +// happens to be all-defaults. The difference shows up on the settings list: +// a defaulted rule keeps the row, a deleted one lets it disappear until the +// application notifies again. +const forget = functionBody("forgetApp"); +if (!/for \(const knownAppId of Object\.keys\(root\.appRules\)\)/.test(forget) + || !/knownAppId !== appId/.test(forget)) + fail("forgetApp does not rebuild the rule map without the forgotten key"); +if (!forget.includes("DesktopPreferences.set(\"notificationAppRules\", next)")) + fail("forgetApp does not persist the deletion"); + +// rememberApplication stamps the bookkeeping fields on every notification. +// Without lastSeenMs nothing can be sorted into "Recent"; without the cached +// name and icon an application that is not installed here draws as a raw id. +const remember = functionBody("rememberApplication"); +for (const stamped of ["lastSeenMs", "name:", "icon:"]) { + if (!remember.includes(stamped)) + fail(`rememberApplication does not stamp ${stamped}`); +} +if (!remember.includes("Date.now()")) + fail("rememberApplication stamps a last-seen time that is not the clock"); + +// ── effectiveUrgency and its four consumers ──────────────────────────────── +// +// The override is only worth having if everything that asks about urgency +// asks the same function. Reading notification.urgency directly anywhere in +// this list would mean "treat as critical" changed the colour but not the +// sound, or the sound but not the banner duration. +const effective = functionBody("effectiveUrgency"); +for (const required of ["rule.urgency === \"low\"", "rule.urgency === \"critical\"", "notification.urgency"]) { + if (!effective.includes(required)) + fail(`effectiveUrgency is missing ${required}`); +} + +const bell = functionBody("playBell"); +if (!bell.includes("root.effectiveUrgency(notification) === NotificationUrgency.Low")) + fail("the bell reads the claimed urgency rather than the effective one, so treat-as-low would still chime"); +// The per-application sound switch is narrower than the enabled switch: the +// notification still arrives and still shows, it just makes no noise. +if (!/appRule\(root\.notificationAppId\(notification\)\)\.sound/.test(bell)) + fail("the bell does not consult the per-application sound switch"); + +const timeout = functionBody("notificationTimeoutMs"); +if (!timeout.includes("root.effectiveUrgency(notification) === NotificationUrgency.Critical")) + fail("the banner duration reads the claimed urgency, so treat-as-critical would look critical but vanish at the normal timeout"); + +if (!card.includes("Notifs.effectiveUrgency(")) + fail("the notification card draws its critical edge from the claimed urgency rather than the effective one"); + +// ── The Do Not Disturb gate ──────────────────────────────────────────────── +// +// It had exactly one exception (a focus mode allow list) and now has two. The +// literal is asserted rather than a loosened shape: a bare if (!doNotDisturb) +// would mean neither exception is consulted, and anything wider would let a +// manual Do Not Disturb be overridden by something nobody switched on. +const handler = functionBody("handleNotification"); +if (!handler.includes("!root.doNotDisturb || FocusModes.allows(appId) || breaksThrough")) + fail("the popup gate is no longer Do Not Disturb plus its two named exceptions"); +if (!handler.includes("Settings.criticalBreaksThrough") + || !handler.includes("root.effectiveUrgency(notification) === NotificationUrgency.Critical")) + fail("the breakthrough exception is not the preference ANDed with the effective critical urgency"); + +// History-only files the notification and stops. It must reach pushHistory +// and the unread count -- that is the whole point of it -- and must not reach +// the popup list or playBell. +if (!handler.includes("rule.display === \"history\"")) + fail("the delivery path never consults the per-application display mode"); +const historyOnlyAt = handler.indexOf("const historyOnly"); +const pushAt = handler.indexOf("root.pushHistory(notification)"); +const popupAt = handler.indexOf("root.popups = [notification].concat(root.popups)"); +if (historyOnlyAt === -1 || pushAt === -1 || popupAt === -1) + fail("could not locate the history-only branch, the history push and the popup push"); +if (!(pushAt < popupAt)) + fail("history-only would skip history as well as the banner, which is not what it means"); +if (!/if \(historyOnly\)/.test(handler)) + fail("history-only is not a branch that bypasses the banner and the bell together"); + +// The preference behind the gate, where the search index and the docs find it. +if (!/key: "criticalBreaksThrough", type: "bool", def: false/.test(schema)) + fail("criticalBreaksThrough is missing from the schema, or does not default to off"); +if (!/key: "criticalBreaksThrough"[\s\S]{0,200}?group: "notifications"/.test(schema)) + fail("criticalBreaksThrough is not in the notifications group, so it would route to the wrong page"); +if (!settings.includes("criticalBreaksThrough: DesktopPreferences.get(\"criticalBreaksThrough\")")) + fail("Settings does not expose criticalBreaksThrough, so the gate reads nothing"); + +// A muted application is rejected before anything is tracked, filed, counted +// or shown. Everything past this point in the handler costs something, so the +// order is the assertion. +const trackedAt = handler.indexOf("notification.tracked = true"); +const mutedAt = handler.indexOf("if (!rule.enabled)"); +if (trackedAt === -1 || mutedAt === -1 || mutedAt > trackedAt) fail("muted applications are not rejected before tracking/history/unread/toast work"); if (!source.includes("root.handleNotification(notification)")) fail("NotificationServer does not delegate delivery to the callable handler"); @@ -83,18 +260,14 @@ if (!source.includes("DesktopPreferences.get(\"notificationAppRules\")")) fail("rules are not read through DesktopPreferences"); if (!source.includes("DesktopPreferences.set(\"notificationAppRules\", next)")) fail("rules are not written through DesktopPreferences"); +// Every rule in the map is rewritten through appRule() on the way out, so a +// write can never persist a shape normalization would have rejected -- which +// is what makes the closed field set above hold for stored data and not only +// for what is read back. if (!source.includes("next[knownAppId] = root.appRule(knownAppId)")) - fail("persisted rules are not normalized to the required three-field shape"); + fail("persisted rules are not normalized on the way to disk"); if (!source.includes("root.fallbackAppRules = next")) fail("missing-schema preference writes do not retain an in-memory fallback"); -// Do Not Disturb still gates popups; it gained exactly one exception, and the -// assertion names that exception rather than being loosened. A bare -// if (!doNotDisturb) would mean the allow list of the active focus mode is -// never consulted. Anything wider would let a manual Do Not Disturb be -// overridden. No apostrophes here: this block is inside a single-quoted shell -// string, and one closes it. -if (!source.includes("if (!root.doNotDisturb || FocusModes.allows(")) - fail("Do Not Disturb no longer gates popups with a focus-mode exception"); for (const required of [ "DesktopEntries.applications.values", "DesktopEntries.byId(appId)", @@ -104,6 +277,15 @@ for (const required of [ fail(`persisted desktop entry ids are not reactively resolved through ${required}`); } +// The per-application controls are asserted over the page AND the components +// it delegates rows to. The rebuild moved the expanded row body into its own +// component, and pinning the page file alone would have made that refactor +// look like the feature being deleted. +const familyPaths = (process.env.PAGE_FAMILY ?? "").split(":").filter(path => path !== ""); +const familyText = [page] + .concat(await Promise.all(familyPaths.map(path => Bun.file(path).text()))) + .join("\n"); + for (const required of [ "Notifs.applications", // Read through a binding on Notifs.appRule rather than a stored copy, so a @@ -114,15 +296,39 @@ for (const required of [ ".enabled", "Notifs.setAppRule" ]) { - if (!page.includes(required)) + if (!familyText.includes(required)) fail(`settings page is missing ${required}`); } +// The four rules the redesign added are only real if the page offers all of +// them. Each was shipped as a field on the rule before the UI existed, and a +// stored field nothing can reach is the same bug the lock-screen pair was. +for (const [required, why] of [ + ["Notifs.forgetApp(", "no way to forget an application, so a rule is permanent once written"], + ["sound:", "no per-application sound switch"], + ["display:", "no banners-versus-history choice"], + ["urgency:", "no per-application urgency override"] +]) { + if (!familyText.includes(required)) + fail(`the application rules card offers ${why} (missing ${required})`); +} +// Wording the spec pins, because it is the only place the consequence of +// forgetting is explained. +if (!familyText.includes("Forget this app")) + fail("the expanded row has no Forget this app action"); +if (!/returns on its next notification/.test(familyText)) + fail("Forget this app does not say the rule comes back when the application notifies again"); + +// The Quiet card owns the breakthrough switch, and it belongs on the page the +// notifications schema group routes to. +if (!page.includes("criticalBreaksThrough")) + fail("the Notifications page does not offer criticalBreaksThrough, which is in its schema group"); + // The lock screen is hyprlock, which cannot render notifications. Two per-app // lock-screen switches once shipped anyway, controlling nothing -- the page // must not grow controls the session cannot honor. for (const forbidden of ["showOnLockScreen", "showContentOnLockScreen"]) { - if (page.includes(forbidden)) + if (familyText.includes(forbidden)) fail(`settings page offers ${forbidden}, which nothing in a hyprlock session reads`); } @@ -149,13 +355,13 @@ cp "$harness_fixture" "$harness" mkdir -p "$data_home/applications" cp "$desktop_entry_fixture" "$data_home/applications/org.persist.App.desktop" -# This is intentionally a copy-local integration dependency. The production -# schema is Claude's change; the runtime contract proves persistence only once -# that key exists and never stages a schema edit from this branch. -perl -0pi -e 's@(\n // ── Capture)@\n {\n key: "notificationAppRules", type: "json", def: {}, group: "notifications", internal: true\n },$1@' \ - "$config_path/config/PreferenceSchema.qml" +# The rule store is a shipped schema key now. This contract used to graft a +# temporary one into its copy of the schema, because the runtime half was +# written before the key landed; grafting it today would define the key twice. rg -q 'key: "notificationAppRules", type: "json"' "$config_path/config/PreferenceSchema.qml" \ - || fail 'temporary schema integration key was not installed' + || fail 'the schema does not define notificationAppRules, so nothing here can persist' +rg -q 'key: "criticalBreaksThrough", type: "bool"' "$config_path/config/PreferenceSchema.qml" \ + || fail 'the schema does not define criticalBreaksThrough, so the breakthrough fixture would prove nothing' mapfile -t dbus_info < <(dbus-daemon --session --fork --print-address=1 --print-pid=1) bus_address="${dbus_info[0]:-}" @@ -190,23 +396,62 @@ start_harness() { start_harness exercise="$(qs_for_test ipc call notification-app-rules-test exercise)" +# The stored rule is checked field by field rather than against a literal, +# because lastSeenMs is a wall clock: it has to be a real stamp (so "Recent" +# can sort by it) and cannot be written down here. jq -e ' .appId == "org.signal.Signal.desktop" and - .initialRules == { - "org.signal.Signal.desktop": { enabled: true } - } and + (.initialRules | keys) == ["org.signal.Signal.desktop"] and + (.initialRules["org.signal.Signal.desktop"] | keys | sort) == + ["display", "enabled", "icon", "lastSeenMs", "name", "sound", "urgency"] and + (.initialRules["org.signal.Signal.desktop"] | + .enabled == true and .sound == true and .display == "banners" and + .urgency == "auto" and .name == "Signal" and + (.lastSeenMs | type) == "number" and .lastSeenMs > 0 and + (.icon | type) == "string") and .muted == { tracked: false, history: 0, popups: 0, unread: 0 } and .dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and - .fallback == { - id: "Fallback Terminal", - application: { id: "Fallback Terminal", name: "Fallback Terminal" } - } + .fallback.id == "Fallback Terminal" and + .fallback.application.id == "Fallback Terminal" and + .fallback.application.name == "Fallback Terminal" ' <<<"$exercise" >/dev/null || fail "runtime notification policy fixture failed: $exercise" -persisted="$(qs_for_test ipc call notification-app-rules-test persist)" +# "History only" is the quiet filing cabinet: it still lands in history and +# still counts as unread, and that is the whole of it -- no banner. The counts +# distinguish it from muting, which is the mistake it would be easy to ship. +history_only="$(qs_for_test ipc call notification-app-rules-test historyOnly)" +jq -e '. == { tracked: true, history: 1, popups: 0, unread: 1 }' <<<"$history_only" >/dev/null \ + || fail "a history-only application did not file quietly: $history_only" + +# Forgetting deletes the key. It comes back on the next notification at its +# defaults -- which is what makes forgetting an undo rather than a mute. +forgotten="$(qs_for_test ipc call notification-app-rules-test forgetting)" jq -e '. == { - "org.persist.App.desktop": { enabled: false } -}' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted" + before: true, after: false, returned: true, soundAfterReturn: true, forgotUnknown: false +}' <<<"$forgotten" >/dev/null || fail "forgetApp did not delete and restore a rule: $forgotten" + +# The urgency override is one answer, read by everything. The timeouts prove +# the banner duration followed it and not only the colour. +urgency="$(qs_for_test ipc call notification-app-rules-test urgency)" +jq -e '. == { + claimed: true, escalated: true, lowered: true, + normalTimeout: true, escalatedTimeout: true +}' <<<"$urgency" >/dev/null || fail "the per-application urgency override was not honored: $urgency" + +# The one exception to Do Not Disturb that is not a focus mode. Off by +# default; on, it lets through exactly the effective-critical notifications +# and nothing else. +breakthrough="$(qs_for_test ipc call notification-app-rules-test breakthrough)" +jq -e '. == { held: 0, through: 1, ordinary: 0, escalated: 1 }' <<<"$breakthrough" >/dev/null \ + || fail "the critical breakthrough gate is wrong: $breakthrough" + +persisted="$(qs_for_test ipc call notification-app-rules-test persist)" +jq -e ' + (keys) == ["org.persist.App.desktop"] and + (.["org.persist.App.desktop"] | + .enabled == false and .sound == true and .display == "banners" and + .urgency == "auto" and (.lastSeenMs | type) == "number" and .lastSeenMs > 0) +' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted" settings_file="$config_home/panama/settings.json" for _ in $(seq 1 40); do diff --git a/tests/quickshell/settings-jump-contract b/tests/quickshell/settings-jump-contract index e12ccde..14a1f26 100755 --- a/tests/quickshell/settings-jump-contract +++ b/tests/quickshell/settings-jump-contract @@ -23,6 +23,7 @@ routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" modules="$repo_dir/config/dot/quickshell/modules" dock_menu="$modules/dock/DockContextMenu.qml" notification_card="$modules/notifications/NotificationCard.qml" +gaming_page="$modules/settings/GamingPage.qml" osd="$modules/osd/Osd.qml" fail() { @@ -79,6 +80,18 @@ grep -qF 'ShellState.openSettings("notifications")' "$notification_card" \ grep -qF 'Popover {' "$notification_card" \ || fail 'notification Settings action is not contained in an overflow menu' +# The Gaming page does not own the Game Mode switch any more -- a focus mode +# does -- so its only honest affordance is a way to reach that mode. It used to +# assign ShellState.settingsPage directly, which skipped resolve() entirely and +# so skipped the guard above; and it pointed at "notifications", which was +# right only while focus modes rendered on that page. +grep -qF 'Open Focus' "$gaming_page" \ + || fail 'the Gaming page no longer offers a way to reach the mode that replaced its switch' +grep -qF 'ShellState.openSettings("focus")' "$gaming_page" \ + || fail 'the Gaming page does not open the Focus tab through openSettings(), so this contract cannot see where it lands' +grep -qF 'ShellState.settingsPage = ' "$gaming_page" \ + && fail 'the Gaming page still sets the settings page by hand, bypassing SettingsRoutes.resolve()' + grep -qF 'acceptedButtons: Qt.RightButton' "$osd" \ || fail 'OSD does not accept its contextual secondary click' grep -qF 'ShellState.openSettings("accessibility")' "$osd" \ diff --git a/tests/quickshell/settings-pages-contract b/tests/quickshell/settings-pages-contract index 26e9716..02f9edb 100755 --- a/tests/quickshell/settings-pages-contract +++ b/tests/quickshell/settings-pages-contract @@ -9,7 +9,7 @@ fail() { exit 1 } -pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Dictation Notifications ScreenIntelligence Health About) +pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Dictation Notifications Focus ScreenIntelligence Health About) for page in "${pages[@]}"; do page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml" [[ -f "$page_file" ]] || fail "${page}Page.qml is missing" @@ -311,7 +311,7 @@ shell_pid="$harness_pid" # four different categories, and the page the tab strip was introduced for. # Routing to a tab must land on that tab, not on whatever its category opens # first, which is the failure the SettingsRoutes resolution could introduce. -pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications screen-intelligence shortcuts services manual about) +pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts services manual about) for page in "${pages[@]}"; do qs_for_test ipc call settings page "$page" >/dev/null for _ in $(seq 1 20); do