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
@@ -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
+185 -21
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 => ({
id: appId,
name: root.applicationLabel(appId, entries, remembered)
})).sort((a, b) => a.name.localeCompare(b.name));
return Object.keys(root.appRules).map(appId => {
const rule = root.appRule(appId);
return {
id: appId,
name: root.applicationLabel(appId, entries, remembered),
icon: root.applicationIcon(appId, rule.icon),
lastSeenMs: rule.lastSeenMs,
rule: rule
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
// Arrival times, keyed by notification id — the protocol carries no
@@ -86,8 +97,11 @@ Singleton {
// Disturb is on (below). Critical urgency and an explicit expireTimeout
// override policy; -1 ("server decides") falls back to it. 0 means "never
// auto-expire" per spec.
// Reads the effective urgency, so an application told to be treated as
// critical also gets the critical banner duration rather than only the
// critical look.
function notificationTimeoutMs(notification: var): int {
if (notification.urgency === NotificationUrgency.Critical)
if (root.effectiveUrgency(notification) === NotificationUrgency.Critical)
return Settings.notificationTimeoutCriticalMs;
if (notification.expireTimeout === 0)
return 0;
@@ -103,37 +117,102 @@ Singleton {
return desktopEntry || String(notification.appName ?? "").trim() || "Notifications";
}
function applicationLabel(appId: string, entries: var, remembered: var): string {
// The installed application behind a rule key, or null. An appId is either
// a desktop entry id or the raw name an application announced itself with,
// so both spellings and the heuristic are tried.
function desktopEntryFor(appId: string): var {
const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId;
const entry = DesktopEntries.byId(appId)
return DesktopEntries.byId(appId)
|| DesktopEntries.byId(desktopId)
|| DesktopEntries.heuristicLookup(appId)
|| DesktopEntries.heuristicLookup(desktopId);
return entry?.name || remembered[appId]?.name || appId;
}
// Live first, cache second: an installed application is named by its
// desktop entry every time, and the name stored on the rule only stands in
// for one that is gone, sandboxed, or not scanned yet. The other way round
// would freeze a name at the moment it was first heard from.
function applicationLabel(appId: string, entries: var, remembered: var): string {
return root.desktopEntryFor(appId)?.name || remembered[appId]?.name || appId;
}
// Same rule for the icon: the desktop entry wins, the cached icon (an
// entry icon, or the appIcon the notification carried) is the fallback.
function applicationIcon(appId: string, cached: string): string {
return root.desktopEntryFor(appId)?.icon || String(cached ?? "");
}
// The durable per-application rule. Everything past `enabled` is optional
// so rules written when this held only `enabled` still load, and anything
// unrecognized -- including the lock-screen pair described below -- is
// dropped rather than carried forward as if something read it.
//
// enabled off rejects the notification before anything else happens
// sound false silences the bell for this application only
// display "history" files it without a banner and without a bell
// urgency "low"/"critical" override what the application claims
// lastSeenMs when it last notified, stamped by rememberApplication
// name, icon cached labels for the settings list (see above: fallback)
function normalizedAppRule(rule: var): var {
const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {};
// Only `enabled` -- see the lock-screen note below for why the rule
// model carries nothing else, and drops old fields on the way through.
return { enabled: source.enabled !== false };
const display = String(source.display ?? "");
const urgency = String(source.urgency ?? "");
const lastSeen = Number(source.lastSeenMs);
return {
enabled: source.enabled !== false,
sound: source.sound !== false,
display: display === "history" ? "history" : "banners",
urgency: urgency === "low" || urgency === "critical" ? urgency : "auto",
lastSeenMs: isFinite(lastSeen) && lastSeen > 0 ? Math.round(lastSeen) : 0,
name: String(source.name ?? ""),
icon: String(source.icon ?? "")
};
}
// A rule with no defaults touched. Everything the page calls "customized"
// is a field that differs from this.
readonly property var defaultAppRule: root.normalizedAppRule({})
function appRule(appId: string): var {
return root.normalizedAppRule(root.appRules[appId]);
}
// Has this application been given a rule that actually says something? The
// bookkeeping fields do not count -- an application is "customized"
// because of a decision somebody made about it, not because it notified.
function isCustomized(appId: string): bool {
const rule = root.appRule(appId);
return rule.enabled !== true || rule.sound !== true
|| rule.display !== "banners" || rule.urgency !== "auto";
}
function setAppRule(appId: string, patch: var): bool {
if (!appId)
return false;
const current = root.appRule(appId);
const changes = patch && typeof patch === "object" ? patch : {};
const merged = Object.assign({}, root.appRule(appId));
// Field by field rather than a blind merge, so a patch cannot smuggle
// a value of the wrong type (or an unknown key) into the stored rule.
if (changes.enabled !== undefined)
merged.enabled = changes.enabled === true;
if (changes.sound !== undefined)
merged.sound = changes.sound === true;
if (changes.display !== undefined)
merged.display = String(changes.display);
if (changes.urgency !== undefined)
merged.urgency = String(changes.urgency);
if (changes.lastSeenMs !== undefined)
merged.lastSeenMs = Number(changes.lastSeenMs);
if (changes.name !== undefined)
merged.name = String(changes.name);
if (changes.icon !== undefined)
merged.icon = String(changes.icon);
const next = {};
for (const knownAppId of Object.keys(root.appRules))
next[knownAppId] = root.appRule(knownAppId);
next[appId] = {
enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true
};
next[appId] = root.normalizedAppRule(merged);
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
root.fallbackAppRules = {};
@@ -142,6 +221,49 @@ Singleton {
return true;
}
// Delete the rule outright, rather than resetting its fields: an
// application with no rule is treated as permissive, so forgetting one is
// undone by its next notification, which writes a fresh default rule.
// The session-only remembered name goes with it, otherwise the row would
// linger under a name nothing is keeping.
function forgetApp(appId: string): bool {
if (!appId || root.appRules[appId] === undefined)
return false;
const next = {};
for (const knownAppId of Object.keys(root.appRules)) {
if (knownAppId !== appId)
next[knownAppId] = root.appRule(knownAppId);
}
const remembered = Object.assign({}, root.rememberedApplications);
delete remembered[appId];
root.rememberedApplications = remembered;
// The session fallback is a whole-map replacement, so a deletion only
// truly lands once the preference write succeeds -- which it does
// whenever the schema key exists, i.e. always outside the transitional
// build this fallback was written for.
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
root.fallbackAppRules = {};
else
root.fallbackAppRules = next;
return true;
}
// What this notification counts as after the application's override: the
// urgency the sender claimed unless a rule disagrees with it. Read by the
// bell, the banner timeout, the Do Not Disturb breakthrough gate, and the
// card's critical edge, so all four agree on one answer.
function effectiveUrgency(notification: var): var {
const rule = root.appRule(root.notificationAppId(notification));
if (rule.urgency === "low")
return NotificationUrgency.Low;
if (rule.urgency === "critical")
return NotificationUrgency.Critical;
return notification.urgency;
}
function rememberApplication(notification: var): string {
const appId = root.notificationAppId(notification);
const next = Object.assign({}, root.rememberedApplications);
@@ -150,8 +272,22 @@ Singleton {
};
root.rememberedApplications = next;
if (root.appRules[appId] === undefined)
root.setAppRule(appId, {});
// Stamped alongside the labels the settings list falls back to when
// the application is not installed here. Coalesced to one write per
// minute per app: every setAppRule bumps the preferences revision and
// restarts the settings.json write debounce, so stamping a chat
// burst's every message would churn the whole preference store for a
// timestamp nobody reads at that resolution.
const rule = root.appRule(appId);
const name = root.applicationLabel(appId, DesktopEntries.applications.values, next);
const icon = root.applicationIcon(appId, String(notification.appIcon ?? ""));
if (Date.now() - rule.lastSeenMs >= 60000 || rule.name !== name || rule.icon !== icon) {
root.setAppRule(appId, {
lastSeenMs: Date.now(),
name: name,
icon: icon
});
}
return appId;
}
@@ -211,7 +347,10 @@ Singleton {
return;
const appId = root.rememberApplication(notification);
if (!root.appRule(appId).enabled)
// Read once: the same rule decides rejection, the bell, and
// whether this is allowed to be a banner at all.
const rule = root.appRule(appId);
if (!rule.enabled)
return;
// Without this the object is destroyed the instant this returns.
@@ -228,10 +367,27 @@ Singleton {
root.unreadCount += 1;
}
// "History only" is the quiet filing cabinet: it was added to
// history and counted as unread above, and that is all it gets --
// no banner, and (never reaching playBell) no sound either.
const historyOnly = rule.display === "history";
// The one exception to Do Not Disturb that is not a focus mode's,
// and it is off unless somebody turned it on. It reads the
// effective urgency, so a per-application override decides who
// gets to claim "critical" rather than the sender alone.
const breaksThrough = Settings.criticalBreaksThrough
&& root.effectiveUrgency(notification) === NotificationUrgency.Critical;
// An exception belongs to the focus mode that is in force. A Do Not
// Disturb switched on by hand has no exceptions and stays absolute,
// because FocusModes.allows is false whenever no mode is active.
if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) {
if (historyOnly) {
// Transient notifications are filed nowhere, so a hidden one
// still needs the release its popup timeout would have given.
if (notification.transient)
root.scheduleTransientExpiry(notification);
} else if (!root.doNotDisturb || FocusModes.allows(appId) || breaksThrough) {
root.popups = [notification].concat(root.popups);
root.playBell(notification);
} else if (notification.transient) {
@@ -260,9 +416,17 @@ Singleton {
if (!SoundFeedback.eventSounds)
return;
// The per-application sound switch. Narrower than turning the
// application off: its notifications still arrive and still show, they
// just stop making noise.
if (!root.appRule(root.notificationAppId(notification)).sound)
return;
// Low urgency is the "you did not need to know this" tier -- battery
// reaching full, a sync completing. It stays silent by design.
if (notification.urgency === NotificationUrgency.Low)
// reaching full, a sync completing. It stays silent by design, and it
// is the effective urgency, so "treat as low" is a way to keep an
// application audible in principle but quiet in practice.
if (root.effectiveUrgency(notification) === NotificationUrgency.Low)
return;
// The freedesktop sound hints. This is the fix for the double chime:
@@ -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" },