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

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 14:25:26 -04:00
parent 07db1068f1
commit d5b6e62515
21 changed files with 1405 additions and 453 deletions
@@ -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 ──────────────────────────────
// { "<appId>": { enabled } } -- lock-screen fields from older rules are
// dropped at normalization; hyprlock cannot render notifications.
// { "<appId>": { 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 ────────────────────────────────────────────────────────
@@ -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,
@@ -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: {
@@ -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 {
@@ -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
Column {
id: appEntry
required property var modelData
readonly property var app: appEntry.modelData
readonly property var rule: Notifs.appRule(appEntry.app.id)
Item {
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 })
}
}
}
}
}
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."
Repeater {
model: FocusModes.modes
delegate: Column {
id: modeEntry
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
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)
}
height: root.recentApps.length > 0 ? 28 : 0
visible: height > 0
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
width: parent.width
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))
}
}
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: root.customizedApps
delegate: NotificationAppRow {
id: customRow
required property var modelData
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: modeEntry.open ? "\u25B4" : "\u25BE"
text: root.allAppsOpen ? "" : ""
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
SettingRow {
Item {
width: parent.width
visible: modeEntry.open
label: "Silence notifications"
detail: "Banners are held until the mode ends"
controlWidth: 48
height: root.allAppsOpen ? 42 : 0
visible: root.allAppsOpen
SettingsToggle {
SearchField {
id: appSearch
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: modeEntry.mode.silence === true
onToggled: value => FocusModes.update(modeEntry.modeId, { silence: value })
placeholder: "Search apps"
}
}
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
: []
model: root.allAppsOpen ? root.expanderApps : []
delegate: NotificationAppRow {
id: expanderRow
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.id ?? "")
controlWidth: 48
required property int index
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)
}
width: parent.width
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))
}
}
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
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
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
}
}
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"
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()
}
}
}
}
@@ -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 {} }
@@ -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.
@@ -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
@@ -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
+184 -20
View File
@@ -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 => ({
return Object.keys(root.appRules).map(appId => {
const rule = root.appRule(appId);
return {
id: appId,
name: root.applicationLabel(appId, entries, remembered)
})).sort((a, b) => a.name.localeCompare(b.name));
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:
@@ -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" },
@@ -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" },
@@ -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
@@ -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
+5 -4
View File
@@ -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**<br>`focusModes` | — | What quiets this machine, and what turns it on |
| **Focus session length**<br>`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5180. |
## 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**<br>`notificationTimeoutCriticalMs` | 0 ms | Zero keeps critical notification banners visible until dismissed. Range 060000. |
| **Notification history**<br>`notificationHistoryLimit` | 100 | Maximum notifications retained in the notification center. Range 10500. |
| **Visible banners**<br>`maxVisibleToasts` | 4 | Maximum notification banners shown at once. Range 18. |
| **Critical alerts break through**<br>`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**<br>`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5180. |
| **Switch back and forth**<br>`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**<br>`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first |
| **Let applications take focus**<br>`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted |
@@ -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.
@@ -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
});
}
}
}
+130 -2
View File
@@ -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'
+287 -42
View File
@@ -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
+13
View File
@@ -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" \
+2 -2
View File
@@ -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