Bound the notification app list, and give Focus a real editor
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -294,14 +294,13 @@ Singleton {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// Grouped with the workspaces rather than with focus, because a group is
|
// Set by the duration chips on the Focus tab, which is also where
|
||||||
// where a setting is found rather than what it is about: the slider that
|
// focusModes renders and where the focus group routes -- one editor,
|
||||||
// sets this is a schema-bound row on WorkspacesPage. The focus group
|
// one page, so search and the docs point at the only place it exists.
|
||||||
// routes to Notifications, which is where focusModes renders.
|
|
||||||
{
|
{
|
||||||
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||||
unit: "min",
|
unit: "min",
|
||||||
group: "workspaces",
|
group: "focus",
|
||||||
label: "Focus session length",
|
label: "Focus session length",
|
||||||
detail: "How long a focus session runs before it ends itself"
|
detail: "How long a focus session runs before it ends itself"
|
||||||
},
|
},
|
||||||
@@ -1490,6 +1489,20 @@ Singleton {
|
|||||||
label: "Visible banners",
|
label: "Visible banners",
|
||||||
detail: "Maximum notification banners shown at once"
|
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 ───────────────────────────────────────────────────────────
|
// ── Sound ───────────────────────────────────────────────────────────
|
||||||
// The two audio preferences that are Panama's own. Everything else on
|
// The two audio preferences that are Panama's own. Everything else on
|
||||||
@@ -1618,8 +1631,17 @@ Singleton {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Per-application notification rules ──────────────────────────────
|
// ── Per-application notification rules ──────────────────────────────
|
||||||
// { "<appId>": { enabled } } -- lock-screen fields from older rules are
|
// { "<appId>": { enabled, sound, display, urgency, lastSeenMs, name,
|
||||||
// dropped at normalization; hyprlock cannot render notifications.
|
// 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
|
// 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
|
// everything: a new application must be able to notify without needing
|
||||||
@@ -1630,7 +1652,7 @@ Singleton {
|
|||||||
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
|
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
|
||||||
internal: true,
|
internal: true,
|
||||||
label: "Application notification rules",
|
label: "Application notification rules",
|
||||||
detail: "Per-application notification and lock-screen visibility preferences"
|
detail: "Per-application notification sound, banner, and urgency preferences"
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Internal ────────────────────────────────────────────────────────
|
// ── Internal ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ Singleton {
|
|||||||
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
|
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
|
||||||
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
|
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
|
||||||
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
|
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 ───────────────────────────────────────────────────────────────
|
// ── Sound ───────────────────────────────────────────────────────────────
|
||||||
// Over-amplification is the clamp ceiling for output volume: off means 1.0,
|
// 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
|
// Critical notifications get a red edge rather than a color wash, so the
|
||||||
// text contrast never changes.
|
// 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 {
|
Rectangle {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
@@ -65,7 +70,7 @@ Rectangle {
|
|||||||
radius: 1.5
|
radius: 1.5
|
||||||
border.width: 0
|
border.width: 0
|
||||||
color: Theme.urgent
|
color: Theme.urgent
|
||||||
visible: root.notification.urgency === NotificationUrgency.Critical
|
visible: Notifs.effectiveUrgency(root.notification) === NotificationUrgency.Critical
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property var defaultAction: {
|
readonly property var defaultAction: {
|
||||||
|
|||||||
@@ -123,13 +123,13 @@ SettingsPage {
|
|||||||
detail: {
|
detail: {
|
||||||
const mode = FocusModes.modes.find(entry => entry.id === "gaming");
|
const mode = FocusModes.modes.find(entry => entry.id === "gaming");
|
||||||
if (!mode)
|
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
|
return mode.enabled === true
|
||||||
? "Handled by the Gaming focus mode, which is on."
|
? "Handled by the Gaming focus mode, which is on."
|
||||||
: "The Gaming focus mode is off, so notifications are not silenced.";
|
: "The Gaming focus mode is off, so notifications are not silenced.";
|
||||||
}
|
}
|
||||||
action: "Open Focus"
|
action: "Open Focus"
|
||||||
onTriggered: ShellState.settingsPage = "notifications"
|
onTriggered: ShellState.openSettings("focus")
|
||||||
}
|
}
|
||||||
|
|
||||||
ToggleRow {
|
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 QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
|
import qs.modules.clipboard
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
// Only one application is expanded at a time; the point is a card you
|
// One application is unfolded at a time; the point is a card you can read,
|
||||||
// can read, not twenty open at once.
|
// 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.
|
readonly property int weekMs: 7 * 24 * 60 * 60 * 1000
|
||||||
property string expandedMode: ""
|
|
||||||
|
|
||||||
// Which mode's exception list is open. Separate from expandedMode so the
|
// Every rule write leaves through here, so a change made in a row goes down
|
||||||
// list does not unfold every time a mode is opened to change a switch.
|
// exactly the same path as one made anywhere else.
|
||||||
property string expandedAllow: ""
|
function editRule(appId: string, patch: var): void {
|
||||||
|
Notifs.setAppRule(appId, patch);
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule edits go through the mode's trigger list rather than replacing
|
function forgetRule(appId: string): void {
|
||||||
// it, so a mode that also has a game or fullscreen trigger keeps it.
|
if (root.expandedApp === appId)
|
||||||
function reschedule(mode: var, changes: var): void {
|
root.expandedApp = "";
|
||||||
FocusModes.update(String(mode.id), {
|
Notifs.forgetApp(appId);
|
||||||
triggers: (mode.triggers ?? []).map(trigger =>
|
|
||||||
trigger.kind === "schedule" ? Object.assign({}, trigger, changes) : trigger)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDay(mode: var, day: int): void {
|
// A rule that differs from the defaults in any field. This is what earns an
|
||||||
const schedule = (mode.triggers ?? []).find(trigger => trigger.kind === "schedule");
|
// 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)
|
if (!schedule)
|
||||||
return;
|
return "The Sleep focus mode has no schedule, so quiet hours are not scheduled";
|
||||||
const days = Array.isArray(schedule.days) ? schedule.days.slice() : [];
|
const when = String(schedule.start ?? "") + " to " + String(schedule.end ?? "")
|
||||||
const at = days.indexOf(day);
|
+ ", " + root.daysText(schedule.days);
|
||||||
if (at >= 0)
|
return sleep.enabled === true
|
||||||
days.splice(at, 1);
|
? "Scheduled through the Sleep focus mode — " + when
|
||||||
else
|
: "The Sleep focus mode is off; it would run " + when;
|
||||||
days.push(day);
|
|
||||||
days.sort((a, b) => a - b);
|
|
||||||
root.reschedule(mode, { days: days });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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."
|
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 {
|
SettingsCard {
|
||||||
title: "Notifications"
|
title: "Quiet"
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Do Not Disturb"
|
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
|
controlWidth: 48
|
||||||
|
|
||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
@@ -67,37 +224,43 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TextRow {
|
ToggleRow { setting: "criticalBreaksThrough" }
|
||||||
label: "Notification history"
|
|
||||||
detail: "Live notifications retained by the shell"
|
|
||||||
value: Notifs.history.length === 1 ? "1 item" : `${Notifs.history.length} items`
|
|
||||||
}
|
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Clear notification history"
|
label: "Quiet hours"
|
||||||
detail: "Dismiss every item currently in the notification center"
|
detail: root.quietHoursDetail()
|
||||||
|
action: "Open Focus"
|
||||||
divider: false
|
divider: false
|
||||||
action: "Clear all"
|
onTriggered: ShellState.openSettings("focus")
|
||||||
enabled: Notifs.history.length > 0
|
|
||||||
onTriggered: Notifs.dismissAll()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Banner behavior"
|
title: "Banners & history"
|
||||||
|
|
||||||
SliderRow { setting: "notificationTimeoutMs" }
|
SliderRow { setting: "notificationTimeoutMs" }
|
||||||
SliderRow {
|
SliderRow {
|
||||||
setting: "notificationTimeoutCriticalMs"
|
setting: "notificationTimeoutCriticalMs"
|
||||||
zeroLabel: "Never"
|
zeroLabel: "Never"
|
||||||
}
|
}
|
||||||
|
SliderRow { setting: "maxVisibleToasts" }
|
||||||
SliderRow { setting: "notificationHistoryLimit" }
|
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 {
|
SettingsCard {
|
||||||
title: "Application rules"
|
title: "Applications"
|
||||||
subtitle: "Apps appear here after they send a notification."
|
subtitle: "Apps appear after their first notification. Recent senders and apps you have customized stay up top; the rest wait in All apps."
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
visible: Notifs.applications.length === 0
|
visible: Notifs.applications.length === 0
|
||||||
@@ -106,332 +269,155 @@ SettingsPage {
|
|||||||
divider: false
|
divider: false
|
||||||
}
|
}
|
||||||
|
|
||||||
Repeater {
|
Item {
|
||||||
model: Notifs.applications
|
width: parent.width
|
||||||
|
height: root.recentApps.length > 0 ? 28 : 0
|
||||||
|
visible: height > 0
|
||||||
|
|
||||||
Column {
|
Text {
|
||||||
id: appEntry
|
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
|
required property var modelData
|
||||||
|
|
||||||
readonly property var app: appEntry.modelData
|
|
||||||
readonly property var rule: Notifs.appRule(appEntry.app.id)
|
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
appId: String(recentRow.modelData.id)
|
||||||
SettingRow {
|
appName: String(recentRow.modelData.name)
|
||||||
width: parent.width
|
iconName: String(recentRow.modelData.icon ?? "")
|
||||||
label: appEntry.app.name
|
rule: Notifs.appRule(recentRow.modelData.id)
|
||||||
detail: appEntry.rule.enabled ? String(appEntry.app.id) : "Notifications off"
|
lastSeen: root.lastSeenText(recentRow.modelData.lastSeenMs)
|
||||||
divider: true
|
expanded: root.expandedApp === String(recentRow.modelData.id)
|
||||||
controlWidth: 48
|
onActivated: root.expandedApp = recentRow.expanded
|
||||||
|
? ""
|
||||||
SettingsToggle {
|
: String(recentRow.modelData.id)
|
||||||
anchors.right: parent.right
|
onEdited: patch => root.editRule(String(recentRow.modelData.id), patch)
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
onForgotten: root.forgetRule(String(recentRow.modelData.id))
|
||||||
checked: appEntry.rule.enabled
|
|
||||||
onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
Item {
|
||||||
title: "Focus modes"
|
width: parent.width
|
||||||
subtitle: FocusModes.active
|
height: root.customizedApps.length > 0 ? 28 : 0
|
||||||
? FocusModes.activeName + " is on because " + FocusModes.activeReason + "."
|
visible: height > 0
|
||||||
: "What quiets this machine, and what turns it on. The first mode whose condition is true wins, so order is priority."
|
|
||||||
|
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 {
|
Repeater {
|
||||||
model: FocusModes.modes
|
model: root.customizedApps
|
||||||
|
|
||||||
delegate: Column {
|
delegate: NotificationAppRow {
|
||||||
id: modeEntry
|
id: customRow
|
||||||
|
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
|
||||||
readonly property var mode: modeEntry.modelData
|
width: parent.width
|
||||||
readonly property string modeId: String(modeEntry.mode.id ?? "")
|
appId: String(customRow.modelData.id)
|
||||||
readonly property bool open: root.expandedMode === modeEntry.modeId
|
appName: String(customRow.modelData.name)
|
||||||
readonly property bool running: FocusModes.activeMode?.id === modeEntry.modeId
|
iconName: String(customRow.modelData.icon ?? "")
|
||||||
readonly property var schedule: (modeEntry.mode.triggers ?? [])
|
rule: Notifs.appRule(customRow.modelData.id)
|
||||||
.find(trigger => trigger.kind === "schedule") ?? null
|
lastSeen: root.lastSeenText(customRow.modelData.lastSeenMs)
|
||||||
|
expanded: root.expandedApp === String(customRow.modelData.id)
|
||||||
|
onActivated: root.expandedApp = customRow.expanded
|
||||||
|
? ""
|
||||||
|
: String(customRow.modelData.id)
|
||||||
|
onEdited: patch => root.editRule(String(customRow.modelData.id), patch)
|
||||||
|
onForgotten: root.forgetRule(String(customRow.modelData.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
visible: root.otherApps.length > 0
|
||||||
|
label: `All apps (${root.otherApps.length})`
|
||||||
|
detail: "The rest of what has notified, alphabetically — search matches a name or an id"
|
||||||
|
activatable: true
|
||||||
|
divider: !root.allAppsOpen
|
||||||
|
controlWidth: 40
|
||||||
|
onActivated: root.allAppsOpen = !root.allAppsOpen
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.allAppsOpen ? "▴" : "▾"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: root.allAppsOpen ? 42 : 0
|
||||||
|
visible: root.allAppsOpen
|
||||||
|
|
||||||
|
SearchField {
|
||||||
|
id: appSearch
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
placeholder: "Search apps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.allAppsOpen ? root.expanderApps : []
|
||||||
|
|
||||||
|
delegate: NotificationAppRow {
|
||||||
|
id: expanderRow
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
appId: String(expanderRow.modelData.id)
|
||||||
SettingRow {
|
appName: String(expanderRow.modelData.name)
|
||||||
width: parent.width
|
iconName: String(expanderRow.modelData.icon ?? "")
|
||||||
label: String(modeEntry.mode.name ?? "")
|
rule: Notifs.appRule(expanderRow.modelData.id)
|
||||||
detail: modeEntry.running
|
lastSeen: root.lastSeenText(expanderRow.modelData.lastSeenMs)
|
||||||
? "On now — " + FocusModes.activeReason
|
expanded: root.expandedApp === String(expanderRow.modelData.id)
|
||||||
: FocusModes.summary(modeEntry.mode)
|
divider: expanderRow.index < root.expanderApps.length - 1
|
||||||
activatable: true
|
onActivated: root.expandedApp = expanderRow.expanded
|
||||||
divider: !modeEntry.open
|
? ""
|
||||||
controlWidth: 92
|
: String(expanderRow.modelData.id)
|
||||||
onActivated: root.expandedMode = modeEntry.open ? "" : modeEntry.modeId
|
onEdited: patch => root.editRule(String(expanderRow.modelData.id), patch)
|
||||||
|
onForgotten: root.forgetRule(String(expanderRow.modelData.id))
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 11
|
|
||||||
|
|
||||||
SettingsToggle {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
checked: modeEntry.mode.enabled === true
|
|
||||||
onToggled: value => FocusModes.setEnabled(modeEntry.modeId, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: modeEntry.open ? "\u25B4" : "\u25BE"
|
|
||||||
color: Theme.fgMuted
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
width: parent.width
|
|
||||||
visible: modeEntry.open
|
|
||||||
label: "Silence notifications"
|
|
||||||
detail: "Banners are held until the mode ends"
|
|
||||||
controlWidth: 48
|
|
||||||
|
|
||||||
SettingsToggle {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
checked: modeEntry.mode.silence === true
|
|
||||||
onToggled: value => FocusModes.update(modeEntry.modeId, { silence: value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
width: parent.width
|
|
||||||
visible: modeEntry.open
|
|
||||||
label: "Keep the screen awake"
|
|
||||||
detail: "Caffeine, for as long as the mode is on"
|
|
||||||
controlWidth: 48
|
|
||||||
divider: modeEntry.schedule !== null
|
|
||||||
|
|
||||||
SettingsToggle {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
checked: modeEntry.mode.keepAwake === true
|
|
||||||
onToggled: value => FocusModes.update(modeEntry.modeId, { keepAwake: value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only modes that actually have a schedule get the schedule
|
|
||||||
// controls; a game trigger has no times to set.
|
|
||||||
TextFieldRow {
|
|
||||||
width: parent.width
|
|
||||||
visible: modeEntry.open && modeEntry.schedule !== null
|
|
||||||
label: "From"
|
|
||||||
detail: "24-hour, such as 23:30"
|
|
||||||
text: String(modeEntry.schedule?.start ?? "")
|
|
||||||
placeholder: "23:30"
|
|
||||||
onAccepted: value => root.reschedule(modeEntry.mode, { start: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
TextFieldRow {
|
|
||||||
width: parent.width
|
|
||||||
visible: modeEntry.open && modeEntry.schedule !== null
|
|
||||||
label: "Until"
|
|
||||||
detail: "A time earlier than the start means the window crosses midnight"
|
|
||||||
text: String(modeEntry.schedule?.end ?? "")
|
|
||||||
placeholder: "07:00"
|
|
||||||
onAccepted: value => root.reschedule(modeEntry.mode, { end: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
width: parent.width
|
|
||||||
visible: modeEntry.open && modeEntry.mode.silence === true
|
|
||||||
label: "May interrupt"
|
|
||||||
detail: (modeEntry.mode.allow ?? []).length === 0
|
|
||||||
? "Nothing gets through while this mode is on"
|
|
||||||
: "Everything else is held until the mode ends"
|
|
||||||
activatable: true
|
|
||||||
controlWidth: 150
|
|
||||||
onActivated: root.expandedAllow =
|
|
||||||
root.expandedAllow === modeEntry.modeId ? "" : modeEntry.modeId
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 9
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: (modeEntry.mode.allow ?? []).length === 0
|
|
||||||
? "Nothing"
|
|
||||||
: (modeEntry.mode.allow ?? []).length + " apps"
|
|
||||||
color: Theme.fgDim
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSize
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: root.expandedAllow === modeEntry.modeId ? "\u25B4" : "\u25BE"
|
|
||||||
color: Theme.fgMuted
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drawn from the applications that have actually sent a
|
|
||||||
// notification, which is the same list the rules above use --
|
|
||||||
// an exception for something that never notifies is not a
|
|
||||||
// choice worth offering.
|
|
||||||
Repeater {
|
|
||||||
model: (modeEntry.open && root.expandedAllow === modeEntry.modeId)
|
|
||||||
? Notifs.applications
|
|
||||||
: []
|
|
||||||
|
|
||||||
delegate: SettingRow {
|
|
||||||
required property var modelData
|
|
||||||
width: parent.width
|
|
||||||
label: String(modelData.name ?? "")
|
|
||||||
detail: String(modelData.id ?? "")
|
|
||||||
controlWidth: 48
|
|
||||||
|
|
||||||
SettingsToggle {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
checked: (modeEntry.mode.allow ?? []).indexOf(String(modelData.id)) >= 0
|
|
||||||
onToggled: value => root.toggleAllowed(modeEntry.mode, String(modelData.id), value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TextRow {
|
|
||||||
width: parent.width
|
|
||||||
visible: modeEntry.open && root.expandedAllow === modeEntry.modeId
|
|
||||||
&& Notifs.applications.length === 0
|
|
||||||
label: "No applications yet"
|
|
||||||
detail: "They appear here once they have sent a notification."
|
|
||||||
value: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
width: parent.width
|
|
||||||
visible: modeEntry.open && modeEntry.schedule !== null
|
|
||||||
label: "On these days"
|
|
||||||
detail: "A window that crosses midnight belongs to the day it starts on"
|
|
||||||
controlWidth: 250
|
|
||||||
divider: false
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: 5
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: [
|
|
||||||
{ value: 1, label: "M" }, { value: 2, label: "T" },
|
|
||||||
{ value: 3, label: "W" }, { value: 4, label: "T" },
|
|
||||||
{ value: 5, label: "F" }, { value: 6, label: "S" },
|
|
||||||
{ value: 0, label: "S" }
|
|
||||||
]
|
|
||||||
|
|
||||||
delegate: Rectangle {
|
|
||||||
id: dayPill
|
|
||||||
|
|
||||||
required property var modelData
|
|
||||||
|
|
||||||
readonly property bool on:
|
|
||||||
(modeEntry.schedule?.days ?? []).indexOf(dayPill.modelData.value) >= 0
|
|
||||||
|
|
||||||
width: 30
|
|
||||||
height: 28
|
|
||||||
radius: 8
|
|
||||||
color: dayPill.on ? Theme.alpha(Theme.accent, 0.22)
|
|
||||||
: Theme.alpha(Theme.fg, 0.06)
|
|
||||||
border.width: dayPill.on ? 1 : 0
|
|
||||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: String(dayPill.modelData.label)
|
|
||||||
color: dayPill.on ? Theme.fg : Theme.fgDim
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
font.weight: dayPill.on ? Font.DemiBold : Font.Medium
|
|
||||||
}
|
|
||||||
|
|
||||||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
|
||||||
TapHandler {
|
|
||||||
onTapped: root.toggleDay(modeEntry.mode, dayPill.modelData.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Focus sessions"
|
|
||||||
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
|
||||||
|
|
||||||
SettingRow {
|
|
||||||
label: "Keep the screen awake"
|
|
||||||
detail: Caffeine.enabled
|
|
||||||
? "The display will not blank or lock while this is on"
|
|
||||||
: "Idle timings on Power & Lock apply normally"
|
|
||||||
controlWidth: 48
|
|
||||||
|
|
||||||
SettingsToggle {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
checked: Caffeine.enabled
|
|
||||||
onToggled: value => Caffeine.enabled = value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingRow {
|
TextRow {
|
||||||
id: durationRow
|
visible: root.allAppsOpen && root.expanderApps.length === 0
|
||||||
|
label: appSearch.text.trim() === "" ? "Nothing else remembered" : "No matching applications"
|
||||||
label: "Default duration"
|
detail: appSearch.text.trim() === ""
|
||||||
detail: "Used by Super+Shift+F and Quick Settings"
|
? "Every application that has notified is already listed above."
|
||||||
controlWidth: 264
|
: "Search matches an application's name or its id."
|
||||||
|
|
||||||
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
|
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 "sound": return soundPage;
|
||||||
case "gaming": return gamingPage;
|
case "gaming": return gamingPage;
|
||||||
case "notifications": return notificationsPage;
|
case "notifications": return notificationsPage;
|
||||||
|
case "focus": return focusPage;
|
||||||
case "screen-intelligence": return screenIntelligencePage;
|
case "screen-intelligence": return screenIntelligencePage;
|
||||||
case "shortcuts": return shortcutsPage;
|
case "shortcuts": return shortcutsPage;
|
||||||
case "mouse": return mousePage;
|
case "mouse": return mousePage;
|
||||||
@@ -260,6 +261,7 @@ Rectangle {
|
|||||||
Component { id: soundPage; SoundPage {} }
|
Component { id: soundPage; SoundPage {} }
|
||||||
Component { id: gamingPage; GamingPage {} }
|
Component { id: gamingPage; GamingPage {} }
|
||||||
Component { id: notificationsPage; NotificationsPage {} }
|
Component { id: notificationsPage; NotificationsPage {} }
|
||||||
|
Component { id: focusPage; FocusPage {} }
|
||||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||||
Component { id: mousePage; MousePage {} }
|
Component { id: mousePage; MousePage {} }
|
||||||
|
|||||||
@@ -30,12 +30,6 @@ SettingsPage {
|
|||||||
ToggleRow { setting: "windowSwallow"; divider: false }
|
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
|
// 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
|
// 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.
|
// 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
|
DisplaysPage 1.0 DisplaysPage.qml
|
||||||
HomePage 1.0 HomePage.qml
|
HomePage 1.0 HomePage.qml
|
||||||
NotificationsPage 1.0 NotificationsPage.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
|
PasswordRow 1.0 PasswordRow.qml
|
||||||
PrintersPage 1.0 PrintersPage.qml
|
PrintersPage 1.0 PrintersPage.qml
|
||||||
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Modes something could switch on without being asked.
|
// 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 =>
|
readonly property var automatic: root.modes.filter(mode =>
|
||||||
mode.enabled === true && (mode.triggers ?? []).some(trigger => trigger.kind !== "manual"))
|
mode.enabled === true && (mode.triggers ?? []).some(trigger => trigger.kind !== "manual"))
|
||||||
|
|
||||||
@@ -213,6 +223,130 @@ Singleton {
|
|||||||
mode.id === id ? Object.assign({}, mode, changes) : mode));
|
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 ────────────────────────────────────────────────────────────
|
// ── applying ────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// What was true before a mode took over, so it can be put back. Recorded at
|
// What was true before a mode took over, so it can be put back. Recorded at
|
||||||
|
|||||||
@@ -60,15 +60,26 @@ Singleton {
|
|||||||
|
|
||||||
readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules)
|
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: {
|
readonly property var applications: {
|
||||||
// byId()/heuristicLookup() do not make a binding by themselves. This
|
// byId()/heuristicLookup() do not make a binding by themselves. This
|
||||||
// read updates persisted app labels once DesktopEntries finishes scan.
|
// read updates persisted app labels once DesktopEntries finishes scan.
|
||||||
const entries = DesktopEntries.applications.values;
|
const entries = DesktopEntries.applications.values;
|
||||||
const remembered = root.rememberedApplications;
|
const remembered = root.rememberedApplications;
|
||||||
return Object.keys(root.appRules).map(appId => ({
|
return Object.keys(root.appRules).map(appId => {
|
||||||
id: appId,
|
const rule = root.appRule(appId);
|
||||||
name: root.applicationLabel(appId, entries, remembered)
|
return {
|
||||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
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
|
// 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
|
// Disturb is on (below). Critical urgency and an explicit expireTimeout
|
||||||
// override policy; -1 ("server decides") falls back to it. 0 means "never
|
// override policy; -1 ("server decides") falls back to it. 0 means "never
|
||||||
// auto-expire" per spec.
|
// 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 {
|
function notificationTimeoutMs(notification: var): int {
|
||||||
if (notification.urgency === NotificationUrgency.Critical)
|
if (root.effectiveUrgency(notification) === NotificationUrgency.Critical)
|
||||||
return Settings.notificationTimeoutCriticalMs;
|
return Settings.notificationTimeoutCriticalMs;
|
||||||
if (notification.expireTimeout === 0)
|
if (notification.expireTimeout === 0)
|
||||||
return 0;
|
return 0;
|
||||||
@@ -103,37 +117,102 @@ Singleton {
|
|||||||
return desktopEntry || String(notification.appName ?? "").trim() || "Notifications";
|
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 desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId;
|
||||||
const entry = DesktopEntries.byId(appId)
|
return DesktopEntries.byId(appId)
|
||||||
|| DesktopEntries.byId(desktopId)
|
|| DesktopEntries.byId(desktopId)
|
||||||
|| DesktopEntries.heuristicLookup(appId)
|
|| DesktopEntries.heuristicLookup(appId)
|
||||||
|| DesktopEntries.heuristicLookup(desktopId);
|
|| 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 {
|
function normalizedAppRule(rule: var): var {
|
||||||
const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {};
|
const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {};
|
||||||
// Only `enabled` -- see the lock-screen note below for why the rule
|
const display = String(source.display ?? "");
|
||||||
// model carries nothing else, and drops old fields on the way through.
|
const urgency = String(source.urgency ?? "");
|
||||||
return { enabled: source.enabled !== false };
|
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 {
|
function appRule(appId: string): var {
|
||||||
return root.normalizedAppRule(root.appRules[appId]);
|
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 {
|
function setAppRule(appId: string, patch: var): bool {
|
||||||
if (!appId)
|
if (!appId)
|
||||||
return false;
|
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 = {};
|
const next = {};
|
||||||
for (const knownAppId of Object.keys(root.appRules))
|
for (const knownAppId of Object.keys(root.appRules))
|
||||||
next[knownAppId] = root.appRule(knownAppId);
|
next[knownAppId] = root.appRule(knownAppId);
|
||||||
next[appId] = {
|
next[appId] = root.normalizedAppRule(merged);
|
||||||
enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true
|
|
||||||
};
|
|
||||||
|
|
||||||
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
|
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
|
||||||
root.fallbackAppRules = {};
|
root.fallbackAppRules = {};
|
||||||
@@ -142,6 +221,49 @@ Singleton {
|
|||||||
return true;
|
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 {
|
function rememberApplication(notification: var): string {
|
||||||
const appId = root.notificationAppId(notification);
|
const appId = root.notificationAppId(notification);
|
||||||
const next = Object.assign({}, root.rememberedApplications);
|
const next = Object.assign({}, root.rememberedApplications);
|
||||||
@@ -150,8 +272,22 @@ Singleton {
|
|||||||
};
|
};
|
||||||
root.rememberedApplications = next;
|
root.rememberedApplications = next;
|
||||||
|
|
||||||
if (root.appRules[appId] === undefined)
|
// Stamped alongside the labels the settings list falls back to when
|
||||||
root.setAppRule(appId, {});
|
// 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;
|
return appId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +347,10 @@ Singleton {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
const appId = root.rememberApplication(notification);
|
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;
|
return;
|
||||||
|
|
||||||
// Without this the object is destroyed the instant this returns.
|
// Without this the object is destroyed the instant this returns.
|
||||||
@@ -228,10 +367,27 @@ Singleton {
|
|||||||
root.unreadCount += 1;
|
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
|
// 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,
|
// Disturb switched on by hand has no exceptions and stays absolute,
|
||||||
// because FocusModes.allows is false whenever no mode is active.
|
// 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.popups = [notification].concat(root.popups);
|
||||||
root.playBell(notification);
|
root.playBell(notification);
|
||||||
} else if (notification.transient) {
|
} else if (notification.transient) {
|
||||||
@@ -260,9 +416,17 @@ Singleton {
|
|||||||
if (!SoundFeedback.eventSounds)
|
if (!SoundFeedback.eventSounds)
|
||||||
return;
|
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
|
// Low urgency is the "you did not need to know this" tier -- battery
|
||||||
// reaching full, a sync completing. It stays silent by design.
|
// reaching full, a sync completing. It stays silent by design, and it
|
||||||
if (notification.urgency === NotificationUrgency.Low)
|
// 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;
|
return;
|
||||||
|
|
||||||
// The freedesktop sound hints. This is the fix for the double chime:
|
// 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
|
// 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
|
// addressed by its first available tab; its own page id is accepted as an
|
||||||
// alias. Three category ids ("applications", "users", "privacy") double as
|
// alias. Four category ids ("notifications", "applications", "users",
|
||||||
// the id of their first tab, which resolves to the same place either way.
|
// "privacy") double as the id of their first tab, which resolves to the
|
||||||
|
// same place either way.
|
||||||
readonly property var categories: [
|
readonly property var categories: [
|
||||||
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [
|
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [
|
||||||
{ page: "home", label: "Overview" },
|
{ page: "home", label: "Overview" },
|
||||||
@@ -42,7 +43,10 @@ Singleton {
|
|||||||
] },
|
] },
|
||||||
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
|
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
|
||||||
{ page: "sound", label: "Sound", icon: "\u{F057E}", 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: "input", label: "Input", icon: "\u{F030C}", tabs: [
|
||||||
{ page: "shortcuts", label: "Keyboard" },
|
{ page: "shortcuts", label: "Keyboard" },
|
||||||
{ page: "mouse", label: "Mouse & Touchpad" },
|
{ page: "mouse", label: "Mouse & Touchpad" },
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ Singleton {
|
|||||||
"wallpaper": "appearance",
|
"wallpaper": "appearance",
|
||||||
"lockAppearance": "appearance",
|
"lockAppearance": "appearance",
|
||||||
"dock": "dock",
|
"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",
|
"display": "displays",
|
||||||
"nightLight": "displays",
|
"nightLight": "displays",
|
||||||
"idle": "power",
|
"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: "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: "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: "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: "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: "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" },
|
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
||||||
# @vicinae.schemaVersion 1
|
# @vicinae.schemaVersion 1
|
||||||
# @vicinae.title Settings: Notifications & Focus
|
# @vicinae.title Settings: Notifications
|
||||||
# @vicinae.mode silent
|
# @vicinae.mode silent
|
||||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
# @vicinae.description Open Notifications & Focus in Settings.
|
# @vicinae.description Open Notifications in Settings.
|
||||||
# @vicinae.keywords ["settings", "focus modes", "notification duration", "critical notification duration", "notification history", "visible banners"]
|
# @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
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page notifications
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
# @vicinae.mode silent
|
# @vicinae.mode silent
|
||||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
# @vicinae.description Open Workspaces in Settings.
|
# @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
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page workspaces
|
||||||
|
|||||||
+5
-4
@@ -4,7 +4,7 @@
|
|||||||
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
||||||
after changing the schema; a contract fails when this copy is stale.
|
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
|
## accessibility
|
||||||
|
|
||||||
@@ -149,11 +149,12 @@ Found on **Appearance**.
|
|||||||
|
|
||||||
## focus
|
## focus
|
||||||
|
|
||||||
Found on **Notifications & Focus**.
|
Found on **Notifications & Focus › Focus**.
|
||||||
|
|
||||||
| Setting | Default | What it does |
|
| Setting | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Focus modes**<br>`focusModes` | — | What quiets this machine, and what turns it on |
|
| **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 5–180. |
|
||||||
|
|
||||||
## gaming
|
## gaming
|
||||||
|
|
||||||
@@ -258,7 +259,7 @@ Found on **Shell › Tiling**.
|
|||||||
|
|
||||||
## notifications
|
## notifications
|
||||||
|
|
||||||
Found on **Notifications & Focus**.
|
Found on **Notifications & Focus › Notifications**.
|
||||||
|
|
||||||
| Setting | Default | What it does |
|
| 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 0–60000. |
|
| **Critical notification duration**<br>`notificationTimeoutCriticalMs` | 0 ms | Zero keeps critical notification banners visible until dismissed. Range 0–60000. |
|
||||||
| **Notification history**<br>`notificationHistoryLimit` | 100 | Maximum notifications retained in the notification center. Range 10–500. |
|
| **Notification history**<br>`notificationHistoryLimit` | 100 | Maximum notifications retained in the notification center. Range 10–500. |
|
||||||
| **Visible banners**<br>`maxVisibleToasts` | 4 | Maximum notification banners shown at once. Range 1–8. |
|
| **Visible banners**<br>`maxVisibleToasts` | 4 | Maximum notification banners shown at once. Range 1–8. |
|
||||||
|
| **Critical alerts break through**<br>`criticalBreaksThrough` | false | Show critical notifications as banners even while Do Not Disturb is on |
|
||||||
|
|
||||||
## pointer
|
## pointer
|
||||||
|
|
||||||
@@ -408,7 +410,6 @@ Found on **Shell › Workspaces**.
|
|||||||
|
|
||||||
| Setting | Default | What it does |
|
| Setting | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Focus session length**<br>`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5–180. |
|
|
||||||
| **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 |
|
| **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 |
|
| **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 |
|
| **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
|
— the locked-session holdovers — both pass unlocked. Later phases append new
|
||||||
deferred contracts below as before; this line is the baseline they diverge
|
deferred contracts below as before; this line is the baseline they diverge
|
||||||
from.
|
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
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
|
import Quickshell.Services.Notifications
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|
||||||
import qs.config
|
import qs.config
|
||||||
@@ -8,6 +9,10 @@ import qs.services
|
|||||||
ShellRoot {
|
ShellRoot {
|
||||||
id: root
|
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 {
|
function notification(idValue: int, desktopEntryValue: string, appNameValue: string): var {
|
||||||
const closeHandlers = [];
|
const closeHandlers = [];
|
||||||
return {
|
return {
|
||||||
@@ -15,6 +20,9 @@ ShellRoot {
|
|||||||
desktopEntry: desktopEntryValue,
|
desktopEntry: desktopEntryValue,
|
||||||
appName: appNameValue,
|
appName: appNameValue,
|
||||||
appIcon: "",
|
appIcon: "",
|
||||||
|
urgency: NotificationUrgency.Normal,
|
||||||
|
expireTimeout: -1,
|
||||||
|
hints: ({}),
|
||||||
transient: false,
|
transient: false,
|
||||||
lastGeneration: false,
|
lastGeneration: false,
|
||||||
tracked: false,
|
tracked: false,
|
||||||
@@ -42,6 +50,24 @@ ShellRoot {
|
|||||||
Notifs.fallbackAppRules = {};
|
Notifs.fallbackAppRules = {};
|
||||||
Notifs.rememberedApplications = {};
|
Notifs.rememberedApplications = {};
|
||||||
DesktopPreferences.set("notificationAppRules", {});
|
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 {
|
IpcHandler {
|
||||||
@@ -108,5 +134,115 @@ ShellRoot {
|
|||||||
function applications(): string {
|
function applications(): string {
|
||||||
return JSON.stringify(Notifs.applications);
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,13 +31,14 @@ service="$repo_dir/config/dot/quickshell/services/FocusModes.qml"
|
|||||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||||
hook="$repo_dir/config/dot/quickshell/scripts/panama-gaming"
|
hook="$repo_dir/config/dot/quickshell/scripts/panama-gaming"
|
||||||
gaming_page="$repo_dir/config/dot/quickshell/modules/settings/GamingPage.qml"
|
gaming_page="$repo_dir/config/dot/quickshell/modules/settings/GamingPage.qml"
|
||||||
|
focus_page="$repo_dir/config/dot/quickshell/modules/settings/FocusPage.qml"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
printf 'focus modes contract: %s\n' "$1" >&2
|
printf 'focus modes contract: %s\n' "$1" >&2
|
||||||
exit 1
|
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"
|
[[ -r "$path" ]] || fail "missing $path"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -158,7 +159,134 @@ grep -q 'function allows' "$service" \
|
|||||||
grep -q 'if (!mode || mode.silence !== true)' "$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'
|
|| 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'
|
|| 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'
|
printf 'focus modes contract: ok\n'
|
||||||
|
|||||||
@@ -18,9 +18,31 @@ fail() {
|
|||||||
[[ -f "$harness_fixture" ]] || fail 'runtime harness fixture is missing'
|
[[ -f "$harness_fixture" ]] || fail 'runtime harness fixture is missing'
|
||||||
[[ -f "$desktop_entry_fixture" ]] || fail 'runtime desktop entry 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 source = await Bun.file(process.env.SERVICE_PATH).text();
|
||||||
const page = await Bun.file(process.env.PAGE_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) {
|
function fail(message) {
|
||||||
console.error(`notification application rules contract: ${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}`);
|
fail(`stable app identity expected ${fixture.expected}, got ${actual}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultRule = normalizedAppRule({});
|
// ── The rule shape ──────────────────────────────────────────────────────────
|
||||||
if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true }))
|
//
|
||||||
fail(`missing rule fields did not default safely: ${JSON.stringify(defaultRule)}`);
|
// 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
|
function sameRule(stored, expected, why) {
|
||||||
// page offered switches for them; normalization must drop dead fields, not
|
const actual = normalizedAppRule(stored);
|
||||||
// carry them forward as if something read them.
|
const actualKeys = Object.keys(actual).sort().join(",");
|
||||||
const explicitRule = normalizedAppRule({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false });
|
const expectedKeys = Object.keys(expected).sort().join(",");
|
||||||
if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false }))
|
if (actualKeys !== expectedKeys)
|
||||||
fail(`stale lock-screen fields were not dropped: ${JSON.stringify(explicitRule)}`);
|
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);
|
functionBody(required);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handler = source.indexOf("function handleNotification(notification: var)");
|
// Forgetting an application must DELETE its rule, not write a rule that
|
||||||
const tracked = source.indexOf("notification.tracked = true", handler);
|
// happens to be all-defaults. The difference shows up on the settings list:
|
||||||
const muted = source.indexOf("!root.appRule(appId).enabled", handler);
|
// a defaulted rule keeps the row, a deleted one lets it disappear until the
|
||||||
if (handler === -1 || tracked === -1 || muted === -1 || muted > tracked)
|
// 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");
|
fail("muted applications are not rejected before tracking/history/unread/toast work");
|
||||||
if (!source.includes("root.handleNotification(notification)"))
|
if (!source.includes("root.handleNotification(notification)"))
|
||||||
fail("NotificationServer does not delegate delivery to the callable handler");
|
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");
|
fail("rules are not read through DesktopPreferences");
|
||||||
if (!source.includes("DesktopPreferences.set(\"notificationAppRules\", next)"))
|
if (!source.includes("DesktopPreferences.set(\"notificationAppRules\", next)"))
|
||||||
fail("rules are not written through DesktopPreferences");
|
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)"))
|
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"))
|
if (!source.includes("root.fallbackAppRules = next"))
|
||||||
fail("missing-schema preference writes do not retain an in-memory fallback");
|
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 [
|
for (const required of [
|
||||||
"DesktopEntries.applications.values",
|
"DesktopEntries.applications.values",
|
||||||
"DesktopEntries.byId(appId)",
|
"DesktopEntries.byId(appId)",
|
||||||
@@ -104,6 +277,15 @@ for (const required of [
|
|||||||
fail(`persisted desktop entry ids are not reactively resolved through ${required}`);
|
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 [
|
for (const required of [
|
||||||
"Notifs.applications",
|
"Notifs.applications",
|
||||||
// Read through a binding on Notifs.appRule rather than a stored copy, so a
|
// Read through a binding on Notifs.appRule rather than a stored copy, so a
|
||||||
@@ -114,15 +296,39 @@ for (const required of [
|
|||||||
".enabled",
|
".enabled",
|
||||||
"Notifs.setAppRule"
|
"Notifs.setAppRule"
|
||||||
]) {
|
]) {
|
||||||
if (!page.includes(required))
|
if (!familyText.includes(required))
|
||||||
fail(`settings page is missing ${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
|
// The lock screen is hyprlock, which cannot render notifications. Two per-app
|
||||||
// lock-screen switches once shipped anyway, controlling nothing -- the page
|
// lock-screen switches once shipped anyway, controlling nothing -- the page
|
||||||
// must not grow controls the session cannot honor.
|
// must not grow controls the session cannot honor.
|
||||||
for (const forbidden of ["showOnLockScreen", "showContentOnLockScreen"]) {
|
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`);
|
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"
|
mkdir -p "$data_home/applications"
|
||||||
cp "$desktop_entry_fixture" "$data_home/applications/org.persist.App.desktop"
|
cp "$desktop_entry_fixture" "$data_home/applications/org.persist.App.desktop"
|
||||||
|
|
||||||
# This is intentionally a copy-local integration dependency. The production
|
# The rule store is a shipped schema key now. This contract used to graft a
|
||||||
# schema is Claude's change; the runtime contract proves persistence only once
|
# temporary one into its copy of the schema, because the runtime half was
|
||||||
# that key exists and never stages a schema edit from this branch.
|
# written before the key landed; grafting it today would define the key twice.
|
||||||
perl -0pi -e 's@(\n // ── Capture)@\n {\n key: "notificationAppRules", type: "json", def: {}, group: "notifications", internal: true\n },$1@' \
|
|
||||||
"$config_path/config/PreferenceSchema.qml"
|
|
||||||
rg -q 'key: "notificationAppRules", type: "json"' "$config_path/config/PreferenceSchema.qml" \
|
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)
|
mapfile -t dbus_info < <(dbus-daemon --session --fork --print-address=1 --print-pid=1)
|
||||||
bus_address="${dbus_info[0]:-}"
|
bus_address="${dbus_info[0]:-}"
|
||||||
@@ -190,23 +396,62 @@ start_harness() {
|
|||||||
|
|
||||||
start_harness
|
start_harness
|
||||||
exercise="$(qs_for_test ipc call notification-app-rules-test exercise)"
|
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 '
|
jq -e '
|
||||||
.appId == "org.signal.Signal.desktop" and
|
.appId == "org.signal.Signal.desktop" and
|
||||||
.initialRules == {
|
(.initialRules | keys) == ["org.signal.Signal.desktop"] and
|
||||||
"org.signal.Signal.desktop": { enabled: true }
|
(.initialRules["org.signal.Signal.desktop"] | keys | sort) ==
|
||||||
} and
|
["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
|
.muted == { tracked: false, history: 0, popups: 0, unread: 0 } and
|
||||||
.dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and
|
.dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and
|
||||||
.fallback == {
|
.fallback.id == "Fallback Terminal" and
|
||||||
id: "Fallback Terminal",
|
.fallback.application.id == "Fallback Terminal" and
|
||||||
application: { id: "Fallback Terminal", name: "Fallback Terminal" }
|
.fallback.application.name == "Fallback Terminal"
|
||||||
}
|
|
||||||
' <<<"$exercise" >/dev/null || fail "runtime notification policy fixture failed: $exercise"
|
' <<<"$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 '. == {
|
jq -e '. == {
|
||||||
"org.persist.App.desktop": { enabled: false }
|
before: true, after: false, returned: true, soundAfterReturn: true, forgotUnknown: false
|
||||||
}' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted"
|
}' <<<"$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"
|
settings_file="$config_home/panama/settings.json"
|
||||||
for _ in $(seq 1 40); do
|
for _ in $(seq 1 40); do
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
|
|||||||
modules="$repo_dir/config/dot/quickshell/modules"
|
modules="$repo_dir/config/dot/quickshell/modules"
|
||||||
dock_menu="$modules/dock/DockContextMenu.qml"
|
dock_menu="$modules/dock/DockContextMenu.qml"
|
||||||
notification_card="$modules/notifications/NotificationCard.qml"
|
notification_card="$modules/notifications/NotificationCard.qml"
|
||||||
|
gaming_page="$modules/settings/GamingPage.qml"
|
||||||
osd="$modules/osd/Osd.qml"
|
osd="$modules/osd/Osd.qml"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
@@ -79,6 +80,18 @@ grep -qF 'ShellState.openSettings("notifications")' "$notification_card" \
|
|||||||
grep -qF 'Popover {' "$notification_card" \
|
grep -qF 'Popover {' "$notification_card" \
|
||||||
|| fail 'notification Settings action is not contained in an overflow menu'
|
|| 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" \
|
grep -qF 'acceptedButtons: Qt.RightButton' "$osd" \
|
||||||
|| fail 'OSD does not accept its contextual secondary click'
|
|| fail 'OSD does not accept its contextual secondary click'
|
||||||
grep -qF 'ShellState.openSettings("accessibility")' "$osd" \
|
grep -qF 'ShellState.openSettings("accessibility")' "$osd" \
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ fail() {
|
|||||||
exit 1
|
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
|
for page in "${pages[@]}"; do
|
||||||
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
||||||
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
|
[[ -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.
|
# 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
|
# 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.
|
# 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
|
for page in "${pages[@]}"; do
|
||||||
qs_for_test ipc call settings page "$page" >/dev/null
|
qs_for_test ipc call settings page "$page" >/dev/null
|
||||||
for _ in $(seq 1 20); do
|
for _ in $(seq 1 20); do
|
||||||
|
|||||||
Reference in New Issue
Block a user