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
|
||||
// where a setting is found rather than what it is about: the slider that
|
||||
// sets this is a schema-bound row on WorkspacesPage. The focus group
|
||||
// routes to Notifications, which is where focusModes renders.
|
||||
// Set by the duration chips on the Focus tab, which is also where
|
||||
// focusModes renders and where the focus group routes -- one editor,
|
||||
// one page, so search and the docs point at the only place it exists.
|
||||
{
|
||||
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||
unit: "min",
|
||||
group: "workspaces",
|
||||
group: "focus",
|
||||
label: "Focus session length",
|
||||
detail: "How long a focus session runs before it ends itself"
|
||||
},
|
||||
@@ -1490,6 +1489,20 @@ Singleton {
|
||||
label: "Visible banners",
|
||||
detail: "Maximum notification banners shown at once"
|
||||
},
|
||||
// The one hole anyone is allowed to punch in Do Not Disturb. Off by
|
||||
// default, because Do Not Disturb that lets something through anyway is
|
||||
// not the thing most people asked for -- and "critical" is the sender's
|
||||
// word, not yours, so an application that calls everything critical
|
||||
// would otherwise defeat the switch on its own say-so. Turned on, it
|
||||
// shows a banner for critical notifications while a mode or a manual Do
|
||||
// Not Disturb is silencing everything else; the per-application urgency
|
||||
// override is how you decide which senders get to claim it.
|
||||
{
|
||||
key: "criticalBreaksThrough", type: "bool", def: false,
|
||||
group: "notifications",
|
||||
label: "Critical alerts break through",
|
||||
detail: "Show critical notifications as banners even while Do Not Disturb is on"
|
||||
},
|
||||
|
||||
// ── Sound ───────────────────────────────────────────────────────────
|
||||
// The two audio preferences that are Panama's own. Everything else on
|
||||
@@ -1618,8 +1631,17 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── Per-application notification rules ──────────────────────────────
|
||||
// { "<appId>": { enabled } } -- lock-screen fields from older rules are
|
||||
// dropped at normalization; hyprlock cannot render notifications.
|
||||
// { "<appId>": { enabled, sound, display, urgency, lastSeenMs, name,
|
||||
// icon } -- every field past `enabled` is optional, so rules written
|
||||
// when this held only `enabled` still load. Lock-screen fields from
|
||||
// older rules are dropped at normalization; hyprlock cannot render
|
||||
// notifications.
|
||||
//
|
||||
// `name` and `icon` are a cache for the settings list, not the source:
|
||||
// resolution stays live-first through DesktopEntries, and these only
|
||||
// stand in for an application that is not installed (or not scanned
|
||||
// yet). `lastSeenMs` is stamped on every notification and is what puts
|
||||
// an application in the "Recent" section.
|
||||
//
|
||||
// Absent means "no rule", which is not the same as a rule that allows
|
||||
// everything: a new application must be able to notify without needing
|
||||
@@ -1630,7 +1652,7 @@ Singleton {
|
||||
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
|
||||
internal: true,
|
||||
label: "Application notification rules",
|
||||
detail: "Per-application notification and lock-screen visibility preferences"
|
||||
detail: "Per-application notification sound, banner, and urgency preferences"
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -82,6 +82,9 @@ Singleton {
|
||||
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
|
||||
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
|
||||
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
|
||||
// The single exception to Do Not Disturb, read by the popup gate in
|
||||
// services/Notifs.qml. Off means Do Not Disturb is absolute.
|
||||
readonly property bool criticalBreaksThrough: DesktopPreferences.get("criticalBreaksThrough")
|
||||
|
||||
// ── Sound ───────────────────────────────────────────────────────────────
|
||||
// Over-amplification is the clamp ceiling for output volume: off means 1.0,
|
||||
|
||||
@@ -56,6 +56,11 @@ Rectangle {
|
||||
|
||||
// Critical notifications get a red edge rather than a color wash, so the
|
||||
// text contrast never changes.
|
||||
//
|
||||
// Read through Notifs.effectiveUrgency rather than off the notification, so
|
||||
// an application the rules treat as critical is marked here too -- and one
|
||||
// demoted to low is not. The bell, the popup timeout and the Do Not Disturb
|
||||
// breakthrough all ask the same question the same way.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
@@ -65,7 +70,7 @@ Rectangle {
|
||||
radius: 1.5
|
||||
border.width: 0
|
||||
color: Theme.urgent
|
||||
visible: root.notification.urgency === NotificationUrgency.Critical
|
||||
visible: Notifs.effectiveUrgency(root.notification) === NotificationUrgency.Critical
|
||||
}
|
||||
|
||||
readonly property var defaultAction: {
|
||||
|
||||
@@ -123,13 +123,13 @@ SettingsPage {
|
||||
detail: {
|
||||
const mode = FocusModes.modes.find(entry => entry.id === "gaming");
|
||||
if (!mode)
|
||||
return "Handled by a focus mode on Notifications & Focus.";
|
||||
return "Handled by a focus mode on the Focus tab.";
|
||||
return mode.enabled === true
|
||||
? "Handled by the Gaming focus mode, which is on."
|
||||
: "The Gaming focus mode is off, so notifications are not silenced.";
|
||||
}
|
||||
action: "Open Focus"
|
||||
onTriggered: ShellState.settingsPage = "notifications"
|
||||
onTriggered: ShellState.openSettings("focus")
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
|
||||
@@ -1,62 +1,219 @@
|
||||
// The notifications tab: what interrupts, how long it stays, and which
|
||||
// applications are allowed to do it.
|
||||
//
|
||||
// The application list used to be every application that had ever notified,
|
||||
// rendered in full, forever. On a machine that has been running for a year that
|
||||
// is a wall nobody reads. It is now bounded: recent senders and applications
|
||||
// whose rule has been changed sit up top, and everything else waits behind a
|
||||
// searchable expander. Rows unfold in place into the rule itself.
|
||||
//
|
||||
// Focus modes moved to their own tab. What stays here is the one line saying a
|
||||
// mode is currently quieting things, because that is the question this page is
|
||||
// opened to answer.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.clipboard
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
// Only one application is expanded at a time; the point is a card you
|
||||
// can read, not twenty open at once.
|
||||
// One application is unfolded at a time; the point is a card you can read,
|
||||
// not twenty open at once.
|
||||
property string expandedApp: ""
|
||||
property bool allAppsOpen: false
|
||||
|
||||
// Which focus mode is open for editing. One at a time, like the app rules.
|
||||
property string expandedMode: ""
|
||||
readonly property int weekMs: 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
// Which mode's exception list is open. Separate from expandedMode so the
|
||||
// list does not unfold every time a mode is opened to change a switch.
|
||||
property string expandedAllow: ""
|
||||
|
||||
function toggleAllowed(mode: var, appId: string, allowed: bool): void {
|
||||
const current = Array.isArray(mode.allow) ? mode.allow.map(String) : [];
|
||||
const at = current.indexOf(appId);
|
||||
if (allowed && at < 0)
|
||||
current.push(appId);
|
||||
else if (!allowed && at >= 0)
|
||||
current.splice(at, 1);
|
||||
FocusModes.update(String(mode.id), { allow: current });
|
||||
// Every rule write leaves through here, so a change made in a row goes down
|
||||
// exactly the same path as one made anywhere else.
|
||||
function editRule(appId: string, patch: var): void {
|
||||
Notifs.setAppRule(appId, patch);
|
||||
}
|
||||
|
||||
// Schedule edits go through the mode's trigger list rather than replacing
|
||||
// it, so a mode that also has a game or fullscreen trigger keeps it.
|
||||
function reschedule(mode: var, changes: var): void {
|
||||
FocusModes.update(String(mode.id), {
|
||||
triggers: (mode.triggers ?? []).map(trigger =>
|
||||
trigger.kind === "schedule" ? Object.assign({}, trigger, changes) : trigger)
|
||||
});
|
||||
function forgetRule(appId: string): void {
|
||||
if (root.expandedApp === appId)
|
||||
root.expandedApp = "";
|
||||
Notifs.forgetApp(appId);
|
||||
}
|
||||
|
||||
function toggleDay(mode: var, day: int): void {
|
||||
const schedule = (mode.triggers ?? []).find(trigger => trigger.kind === "schedule");
|
||||
// A rule that differs from the defaults in any field. This is what earns an
|
||||
// application a place above the fold even when it has not notified lately.
|
||||
function customized(rule: var): bool {
|
||||
return rule.enabled === false
|
||||
|| rule.sound === false
|
||||
|| String(rule.display ?? "banners") !== "banners"
|
||||
|| String(rule.urgency ?? "auto") !== "auto";
|
||||
}
|
||||
|
||||
// Relative rather than a timestamp: "three days ago" is the question being
|
||||
// asked, and a clock time from last March answers a different one.
|
||||
function lastSeenText(lastSeenMs: var): string {
|
||||
const at = Number(lastSeenMs ?? 0);
|
||||
if (!at)
|
||||
return "";
|
||||
const days = Math.floor((Date.now() - at) / 86400000);
|
||||
if (days <= 0)
|
||||
return "Today";
|
||||
if (days === 1)
|
||||
return "Yesterday";
|
||||
if (days < 7)
|
||||
return days + " days ago";
|
||||
return Qt.formatDateTime(new Date(at), "d MMM");
|
||||
}
|
||||
|
||||
// Spelled the same way the Focus tab spells it, so the two pages describe
|
||||
// one schedule in one voice.
|
||||
function daysText(days: var): string {
|
||||
const list = Array.isArray(days) ? days.map(Number).slice().sort((a, b) => a - b) : [];
|
||||
if (list.length === 0)
|
||||
return "no days";
|
||||
if (list.length === 7)
|
||||
return "every day";
|
||||
if (list.join(",") === "1,2,3,4,5")
|
||||
return "weekdays";
|
||||
if (list.join(",") === "0,6")
|
||||
return "weekends";
|
||||
const names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
return list.map(day => names[day]).join(", ");
|
||||
}
|
||||
|
||||
// Quiet hours are the Sleep mode's schedule. Rather than inventing a second
|
||||
// place to set them, the row states what Sleep is actually doing and hands
|
||||
// over to the tab that owns it.
|
||||
function quietHoursDetail(): string {
|
||||
const sleep = FocusModes.modes.find(mode => String(mode.id) === "sleep");
|
||||
if (!sleep)
|
||||
return "There is no Sleep focus mode, so nothing is scheduled";
|
||||
const schedule = (sleep.triggers ?? []).find(trigger => trigger.kind === "schedule");
|
||||
if (!schedule)
|
||||
return;
|
||||
const days = Array.isArray(schedule.days) ? schedule.days.slice() : [];
|
||||
const at = days.indexOf(day);
|
||||
if (at >= 0)
|
||||
days.splice(at, 1);
|
||||
else
|
||||
days.push(day);
|
||||
days.sort((a, b) => a - b);
|
||||
root.reschedule(mode, { days: days });
|
||||
return "The Sleep focus mode has no schedule, so quiet hours are not scheduled";
|
||||
const when = String(schedule.start ?? "") + " to " + String(schedule.end ?? "")
|
||||
+ ", " + root.daysText(schedule.days);
|
||||
return sleep.enabled === true
|
||||
? "Scheduled through the Sleep focus mode — " + when
|
||||
: "The Sleep focus mode is off; it would run " + when;
|
||||
}
|
||||
|
||||
title: "Notifications & Focus"
|
||||
readonly property var recentApps: {
|
||||
const now = Date.now();
|
||||
return Notifs.applications
|
||||
.filter(app => Number(app.lastSeenMs ?? 0) > 0
|
||||
&& now - Number(app.lastSeenMs) < root.weekMs)
|
||||
.sort((a, b) => Number(b.lastSeenMs) - Number(a.lastSeenMs));
|
||||
}
|
||||
|
||||
// Sections are partitioned by id rather than by object identity: these are
|
||||
// plain objects rebuilt by a binding, and an application appearing in two
|
||||
// sections at once is the failure that would cause.
|
||||
readonly property var recentIds: root.recentApps.map(app => String(app.id))
|
||||
|
||||
readonly property var customizedApps: Notifs.applications.filter(app =>
|
||||
root.recentIds.indexOf(String(app.id)) < 0 && root.customized(app.rule))
|
||||
|
||||
readonly property var customizedIds: root.customizedApps.map(app => String(app.id))
|
||||
|
||||
// Everything not already on screen. Notifs.applications arrives sorted by
|
||||
// name, so this stays alphabetical for free.
|
||||
readonly property var otherApps: Notifs.applications.filter(app =>
|
||||
root.recentIds.indexOf(String(app.id)) < 0
|
||||
&& root.customizedIds.indexOf(String(app.id)) < 0)
|
||||
|
||||
// With nothing typed the expander holds what is not shown above. A typed
|
||||
// query searches the whole universe instead -- a search box that cannot
|
||||
// find an application you can see is worse than a duplicated row.
|
||||
readonly property var expanderApps: {
|
||||
const needle = appSearch.text.trim().toLowerCase();
|
||||
if (needle === "")
|
||||
return root.otherApps;
|
||||
return Notifs.applications.filter(app =>
|
||||
(String(app.name) + " " + String(app.id)).toLowerCase().indexOf(needle) >= 0);
|
||||
}
|
||||
|
||||
readonly property string allowSummary: {
|
||||
const allow = FocusModes.allowedApps;
|
||||
if (allow.length === 0)
|
||||
return "nothing may interrupt";
|
||||
const names = allow.map(id => {
|
||||
const app = Notifs.applications.find(entry => String(entry.id) === id);
|
||||
return app ? String(app.name) : id;
|
||||
});
|
||||
if (names.length <= 2)
|
||||
return names.join(" and ") + " may interrupt";
|
||||
return names.slice(0, 2).join(", ") + " and " + (names.length - 2)
|
||||
+ " more may interrupt";
|
||||
}
|
||||
|
||||
title: "Notifications"
|
||||
lede: "Control interruptions without losing useful history."
|
||||
|
||||
// Why the machine is quiet, at the top, where the question is asked.
|
||||
Rectangle {
|
||||
id: activeBanner
|
||||
|
||||
width: parent.width
|
||||
visible: FocusModes.activeMode !== null
|
||||
height: visible ? Math.max(58, bannerCopy.implicitHeight + 24) : 0
|
||||
radius: Theme.cardRadius + 1
|
||||
color: Theme.alpha(Theme.ok, 0.08)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.ok, 0.3)
|
||||
|
||||
Column {
|
||||
id: bannerCopy
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 15
|
||||
anchors.right: bannerButton.left
|
||||
anchors.rightMargin: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: FocusModes.activeMode?.silence === true
|
||||
? FocusModes.activeName + " is quieting notifications"
|
||||
: FocusModes.activeName + " is on"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: {
|
||||
const why = "Because " + FocusModes.activeReason;
|
||||
return FocusModes.activeMode?.silence === true
|
||||
? why + " · " + root.allowSummary
|
||||
: why + " · notifications are unaffected";
|
||||
}
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: bannerButton
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Open Focus"
|
||||
onClicked: ShellState.openSettings("focus")
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Notifications"
|
||||
title: "Quiet"
|
||||
|
||||
SettingRow {
|
||||
label: "Do Not Disturb"
|
||||
detail: "Keep notifications in the center but suppress banners"
|
||||
detail: "Banners stop; everything still lands in the notification center"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
@@ -67,37 +224,43 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Notification history"
|
||||
detail: "Live notifications retained by the shell"
|
||||
value: Notifs.history.length === 1 ? "1 item" : `${Notifs.history.length} items`
|
||||
}
|
||||
ToggleRow { setting: "criticalBreaksThrough" }
|
||||
|
||||
ActionRow {
|
||||
label: "Clear notification history"
|
||||
detail: "Dismiss every item currently in the notification center"
|
||||
label: "Quiet hours"
|
||||
detail: root.quietHoursDetail()
|
||||
action: "Open Focus"
|
||||
divider: false
|
||||
action: "Clear all"
|
||||
enabled: Notifs.history.length > 0
|
||||
onTriggered: Notifs.dismissAll()
|
||||
onTriggered: ShellState.openSettings("focus")
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Banner behavior"
|
||||
title: "Banners & history"
|
||||
|
||||
SliderRow { setting: "notificationTimeoutMs" }
|
||||
SliderRow {
|
||||
setting: "notificationTimeoutCriticalMs"
|
||||
zeroLabel: "Never"
|
||||
}
|
||||
SliderRow { setting: "maxVisibleToasts" }
|
||||
SliderRow { setting: "notificationHistoryLimit" }
|
||||
SliderRow { setting: "maxVisibleToasts"; divider: false }
|
||||
|
||||
ActionRow {
|
||||
label: "Notification history"
|
||||
detail: Notifs.history.length === 1
|
||||
? "1 kept now · the oldest are dropped past the limit"
|
||||
: `${Notifs.history.length} kept now · the oldest are dropped past the limit`
|
||||
action: "Clear"
|
||||
enabled: Notifs.history.length > 0
|
||||
divider: false
|
||||
onTriggered: Notifs.dismissAll()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Application rules"
|
||||
subtitle: "Apps appear here after they send a notification."
|
||||
title: "Applications"
|
||||
subtitle: "Apps appear after their first notification. Recent senders and apps you have customized stay up top; the rest wait in All apps."
|
||||
|
||||
TextRow {
|
||||
visible: Notifs.applications.length === 0
|
||||
@@ -106,332 +269,155 @@ SettingsPage {
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Notifs.applications
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.recentApps.length > 0 ? 28 : 0
|
||||
visible: height > 0
|
||||
|
||||
Column {
|
||||
id: appEntry
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 4
|
||||
text: "Recent"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
font.weight: Font.Bold
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.recentApps
|
||||
|
||||
delegate: NotificationAppRow {
|
||||
id: recentRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property var app: appEntry.modelData
|
||||
readonly property var rule: Notifs.appRule(appEntry.app.id)
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: appEntry.app.name
|
||||
detail: appEntry.rule.enabled ? String(appEntry.app.id) : "Notifications off"
|
||||
divider: true
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: appEntry.rule.enabled
|
||||
onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value })
|
||||
}
|
||||
}
|
||||
appId: String(recentRow.modelData.id)
|
||||
appName: String(recentRow.modelData.name)
|
||||
iconName: String(recentRow.modelData.icon ?? "")
|
||||
rule: Notifs.appRule(recentRow.modelData.id)
|
||||
lastSeen: root.lastSeenText(recentRow.modelData.lastSeenMs)
|
||||
expanded: root.expandedApp === String(recentRow.modelData.id)
|
||||
onActivated: root.expandedApp = recentRow.expanded
|
||||
? ""
|
||||
: String(recentRow.modelData.id)
|
||||
onEdited: patch => root.editRule(String(recentRow.modelData.id), patch)
|
||||
onForgotten: root.forgetRule(String(recentRow.modelData.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus modes"
|
||||
subtitle: FocusModes.active
|
||||
? FocusModes.activeName + " is on because " + FocusModes.activeReason + "."
|
||||
: "What quiets this machine, and what turns it on. The first mode whose condition is true wins, so order is priority."
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.customizedApps.length > 0 ? 28 : 0
|
||||
visible: height > 0
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 4
|
||||
text: "Customized"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
font.weight: Font.Bold
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: FocusModes.modes
|
||||
model: root.customizedApps
|
||||
|
||||
delegate: Column {
|
||||
id: modeEntry
|
||||
delegate: NotificationAppRow {
|
||||
id: customRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property var mode: modeEntry.modelData
|
||||
readonly property string modeId: String(modeEntry.mode.id ?? "")
|
||||
readonly property bool open: root.expandedMode === modeEntry.modeId
|
||||
readonly property bool running: FocusModes.activeMode?.id === modeEntry.modeId
|
||||
readonly property var schedule: (modeEntry.mode.triggers ?? [])
|
||||
.find(trigger => trigger.kind === "schedule") ?? null
|
||||
width: parent.width
|
||||
appId: String(customRow.modelData.id)
|
||||
appName: String(customRow.modelData.name)
|
||||
iconName: String(customRow.modelData.icon ?? "")
|
||||
rule: Notifs.appRule(customRow.modelData.id)
|
||||
lastSeen: root.lastSeenText(customRow.modelData.lastSeenMs)
|
||||
expanded: root.expandedApp === String(customRow.modelData.id)
|
||||
onActivated: root.expandedApp = customRow.expanded
|
||||
? ""
|
||||
: String(customRow.modelData.id)
|
||||
onEdited: patch => root.editRule(String(customRow.modelData.id), patch)
|
||||
onForgotten: root.forgetRule(String(customRow.modelData.id))
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
visible: root.otherApps.length > 0
|
||||
label: `All apps (${root.otherApps.length})`
|
||||
detail: "The rest of what has notified, alphabetically — search matches a name or an id"
|
||||
activatable: true
|
||||
divider: !root.allAppsOpen
|
||||
controlWidth: 40
|
||||
onActivated: root.allAppsOpen = !root.allAppsOpen
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.allAppsOpen ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.allAppsOpen ? 42 : 0
|
||||
visible: root.allAppsOpen
|
||||
|
||||
SearchField {
|
||||
id: appSearch
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
placeholder: "Search apps"
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.allAppsOpen ? root.expanderApps : []
|
||||
|
||||
delegate: NotificationAppRow {
|
||||
id: expanderRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: String(modeEntry.mode.name ?? "")
|
||||
detail: modeEntry.running
|
||||
? "On now — " + FocusModes.activeReason
|
||||
: FocusModes.summary(modeEntry.mode)
|
||||
activatable: true
|
||||
divider: !modeEntry.open
|
||||
controlWidth: 92
|
||||
onActivated: root.expandedMode = modeEntry.open ? "" : modeEntry.modeId
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 11
|
||||
|
||||
SettingsToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: modeEntry.mode.enabled === true
|
||||
onToggled: value => FocusModes.setEnabled(modeEntry.modeId, value)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modeEntry.open ? "\u25B4" : "\u25BE"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open
|
||||
label: "Silence notifications"
|
||||
detail: "Banners are held until the mode ends"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: modeEntry.mode.silence === true
|
||||
onToggled: value => FocusModes.update(modeEntry.modeId, { silence: value })
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open
|
||||
label: "Keep the screen awake"
|
||||
detail: "Caffeine, for as long as the mode is on"
|
||||
controlWidth: 48
|
||||
divider: modeEntry.schedule !== null
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: modeEntry.mode.keepAwake === true
|
||||
onToggled: value => FocusModes.update(modeEntry.modeId, { keepAwake: value })
|
||||
}
|
||||
}
|
||||
|
||||
// Only modes that actually have a schedule get the schedule
|
||||
// controls; a game trigger has no times to set.
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.schedule !== null
|
||||
label: "From"
|
||||
detail: "24-hour, such as 23:30"
|
||||
text: String(modeEntry.schedule?.start ?? "")
|
||||
placeholder: "23:30"
|
||||
onAccepted: value => root.reschedule(modeEntry.mode, { start: value })
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.schedule !== null
|
||||
label: "Until"
|
||||
detail: "A time earlier than the start means the window crosses midnight"
|
||||
text: String(modeEntry.schedule?.end ?? "")
|
||||
placeholder: "07:00"
|
||||
onAccepted: value => root.reschedule(modeEntry.mode, { end: value })
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.mode.silence === true
|
||||
label: "May interrupt"
|
||||
detail: (modeEntry.mode.allow ?? []).length === 0
|
||||
? "Nothing gets through while this mode is on"
|
||||
: "Everything else is held until the mode ends"
|
||||
activatable: true
|
||||
controlWidth: 150
|
||||
onActivated: root.expandedAllow =
|
||||
root.expandedAllow === modeEntry.modeId ? "" : modeEntry.modeId
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: (modeEntry.mode.allow ?? []).length === 0
|
||||
? "Nothing"
|
||||
: (modeEntry.mode.allow ?? []).length + " apps"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.expandedAllow === modeEntry.modeId ? "\u25B4" : "\u25BE"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn from the applications that have actually sent a
|
||||
// notification, which is the same list the rules above use --
|
||||
// an exception for something that never notifies is not a
|
||||
// choice worth offering.
|
||||
Repeater {
|
||||
model: (modeEntry.open && root.expandedAllow === modeEntry.modeId)
|
||||
? Notifs.applications
|
||||
: []
|
||||
|
||||
delegate: SettingRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: String(modelData.id ?? "")
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: (modeEntry.mode.allow ?? []).indexOf(String(modelData.id)) >= 0
|
||||
onToggled: value => root.toggleAllowed(modeEntry.mode, String(modelData.id), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && root.expandedAllow === modeEntry.modeId
|
||||
&& Notifs.applications.length === 0
|
||||
label: "No applications yet"
|
||||
detail: "They appear here once they have sent a notification."
|
||||
value: ""
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: modeEntry.open && modeEntry.schedule !== null
|
||||
label: "On these days"
|
||||
detail: "A window that crosses midnight belongs to the day it starts on"
|
||||
controlWidth: 250
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ value: 1, label: "M" }, { value: 2, label: "T" },
|
||||
{ value: 3, label: "W" }, { value: 4, label: "T" },
|
||||
{ value: 5, label: "F" }, { value: 6, label: "S" },
|
||||
{ value: 0, label: "S" }
|
||||
]
|
||||
|
||||
delegate: Rectangle {
|
||||
id: dayPill
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool on:
|
||||
(modeEntry.schedule?.days ?? []).indexOf(dayPill.modelData.value) >= 0
|
||||
|
||||
width: 30
|
||||
height: 28
|
||||
radius: 8
|
||||
color: dayPill.on ? Theme.alpha(Theme.accent, 0.22)
|
||||
: Theme.alpha(Theme.fg, 0.06)
|
||||
border.width: dayPill.on ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: String(dayPill.modelData.label)
|
||||
color: dayPill.on ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: dayPill.on ? Font.DemiBold : Font.Medium
|
||||
}
|
||||
|
||||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler {
|
||||
onTapped: root.toggleDay(modeEntry.mode, dayPill.modelData.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus sessions"
|
||||
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
||||
|
||||
SettingRow {
|
||||
label: "Keep the screen awake"
|
||||
detail: Caffeine.enabled
|
||||
? "The display will not blank or lock while this is on"
|
||||
: "Idle timings on Power & Lock apply normally"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Caffeine.enabled
|
||||
onToggled: value => Caffeine.enabled = value
|
||||
appId: String(expanderRow.modelData.id)
|
||||
appName: String(expanderRow.modelData.name)
|
||||
iconName: String(expanderRow.modelData.icon ?? "")
|
||||
rule: Notifs.appRule(expanderRow.modelData.id)
|
||||
lastSeen: root.lastSeenText(expanderRow.modelData.lastSeenMs)
|
||||
expanded: root.expandedApp === String(expanderRow.modelData.id)
|
||||
divider: expanderRow.index < root.expanderApps.length - 1
|
||||
onActivated: root.expandedApp = expanderRow.expanded
|
||||
? ""
|
||||
: String(expanderRow.modelData.id)
|
||||
onEdited: patch => root.editRule(String(expanderRow.modelData.id), patch)
|
||||
onForgotten: root.forgetRule(String(expanderRow.modelData.id))
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
id: durationRow
|
||||
|
||||
label: "Default duration"
|
||||
detail: "Used by Super+Shift+F and Quick Settings"
|
||||
controlWidth: 264
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: [25, 45, 60, 90]
|
||||
|
||||
SettingsButton {
|
||||
required property int modelData
|
||||
|
||||
text: `${modelData}m`
|
||||
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
|
||||
onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
|
||||
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
|
||||
TextRow {
|
||||
visible: root.allAppsOpen && root.expanderApps.length === 0
|
||||
label: appSearch.text.trim() === "" ? "Nothing else remembered" : "No matching applications"
|
||||
detail: appSearch.text.trim() === ""
|
||||
? "Every application that has notified is already listed above."
|
||||
: "Search matches an application's name or its id."
|
||||
divider: false
|
||||
controlWidth: 120
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: FocusSession.active ? "Show controls" : "Start focus"
|
||||
tone: FocusSession.active ? "normal" : "accent"
|
||||
onClicked: FocusSession.reveal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ Rectangle {
|
||||
case "sound": return soundPage;
|
||||
case "gaming": return gamingPage;
|
||||
case "notifications": return notificationsPage;
|
||||
case "focus": return focusPage;
|
||||
case "screen-intelligence": return screenIntelligencePage;
|
||||
case "shortcuts": return shortcutsPage;
|
||||
case "mouse": return mousePage;
|
||||
@@ -260,6 +261,7 @@ Rectangle {
|
||||
Component { id: soundPage; SoundPage {} }
|
||||
Component { id: gamingPage; GamingPage {} }
|
||||
Component { id: notificationsPage; NotificationsPage {} }
|
||||
Component { id: focusPage; FocusPage {} }
|
||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||
Component { id: mousePage; MousePage {} }
|
||||
|
||||
@@ -30,12 +30,6 @@ SettingsPage {
|
||||
ToggleRow { setting: "windowSwallow"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus"
|
||||
|
||||
SliderRow { setting: "focusDurationMinutes"; divider: false }
|
||||
}
|
||||
|
||||
// Saved here, not created here. A layout is worth recording at the moment
|
||||
// you have it right, so saving is a launcher command; this is where the
|
||||
// ones you kept are reviewed and the ones you did not are removed.
|
||||
|
||||
@@ -22,6 +22,10 @@ SyncPage 1.0 SyncPage.qml
|
||||
DisplaysPage 1.0 DisplaysPage.qml
|
||||
HomePage 1.0 HomePage.qml
|
||||
NotificationsPage 1.0 NotificationsPage.qml
|
||||
NotificationAppRow 1.0 NotificationAppRow.qml
|
||||
FocusPage 1.0 FocusPage.qml
|
||||
FocusModeRow 1.0 FocusModeRow.qml
|
||||
FocusAllowChips 1.0 FocusAllowChips.qml
|
||||
PasswordRow 1.0 PasswordRow.qml
|
||||
PrintersPage 1.0 PrintersPage.qml
|
||||
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
||||
|
||||
@@ -30,6 +30,16 @@ Singleton {
|
||||
}
|
||||
|
||||
// Modes something could switch on without being asked.
|
||||
//
|
||||
// A mode whose only trigger is "manual" is deliberately not here, and that
|
||||
// is its whole activation story: triggerActive() answers false for
|
||||
// "manual", so such a mode is never `activeMode` and never silences
|
||||
// anything by itself. Switching it on means exactly one thing -- its
|
||||
// `enabled` flag is true -- and the timed session that people actually
|
||||
// start by hand is FocusSession, which owns Do Not Disturb itself and does
|
||||
// not read this list. That split is the single-owner rule from the
|
||||
// contract, so a manual mode is a saved intent rather than a second
|
||||
// silencer, and the editor says so rather than pretending otherwise.
|
||||
readonly property var automatic: root.modes.filter(mode =>
|
||||
mode.enabled === true && (mode.triggers ?? []).some(trigger => trigger.kind !== "manual"))
|
||||
|
||||
@@ -213,6 +223,130 @@ Singleton {
|
||||
mode.id === id ? Object.assign({}, mode, changes) : mode));
|
||||
}
|
||||
|
||||
// ── Editing the list ────────────────────────────────────────────────────
|
||||
//
|
||||
// The list is the priority order (first match wins), so creating appends
|
||||
// and moving is a real edit rather than a view preference. Every function
|
||||
// here answers false when it was asked about a mode that is not there,
|
||||
// instead of silently writing the list back unchanged.
|
||||
|
||||
function hasMode(id: string): bool {
|
||||
return root.modes.some(mode => mode.id === id);
|
||||
}
|
||||
|
||||
// A readable id derived from the name, made unique by suffix. Ids are
|
||||
// referenced by nothing outside this list -- GamingPage looks up "gaming"
|
||||
// and that mode ships with the schema -- so this only has to stay stable
|
||||
// for itself.
|
||||
function uniqueId(name: string): string {
|
||||
const base = String(name ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "mode";
|
||||
let candidate = base;
|
||||
let suffix = 2;
|
||||
while (root.hasMode(candidate)) {
|
||||
candidate = base + "-" + suffix;
|
||||
suffix++;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// Returns the new mode's id, so the page can open the row it just made.
|
||||
// A new mode is manual and silencing: it does nothing until somebody picks
|
||||
// what turns it on, which is the safe direction for a thing whose job is
|
||||
// to quiet the machine.
|
||||
function createMode(name: string): string {
|
||||
const label = String(name ?? "").trim() || "New mode";
|
||||
const id = root.uniqueId(label);
|
||||
root.save(root.modes.concat([{
|
||||
id: id,
|
||||
name: label,
|
||||
enabled: true,
|
||||
triggers: [{ kind: "manual" }],
|
||||
silence: true,
|
||||
keepAwake: false,
|
||||
allow: []
|
||||
}]));
|
||||
return id;
|
||||
}
|
||||
|
||||
// Deleting the mode in force needs no special handling: activeMode is a
|
||||
// binding over this list, so it recomputes to null and the release path
|
||||
// below puts Do Not Disturb and Caffeine back.
|
||||
function removeMode(id: string): bool {
|
||||
if (!root.hasMode(id))
|
||||
return false;
|
||||
root.save(root.modes.filter(mode => mode.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
function renameMode(id: string, name: string): bool {
|
||||
const label = String(name ?? "").trim();
|
||||
if (!label || !root.hasMode(id))
|
||||
return false;
|
||||
root.update(id, { name: label });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Order is priority, so this is what "make Gaming beat Sleep" is. A move
|
||||
// off either end is a no-op rather than a wrap, because the buttons that
|
||||
// call this are disabled at the ends and a keyboard repeat should stop
|
||||
// there rather than teleport the row.
|
||||
function moveMode(id: string, delta: int): bool {
|
||||
const next = root.modes.slice();
|
||||
const from = next.findIndex(mode => mode.id === id);
|
||||
const to = from + Math.round(delta);
|
||||
if (from < 0 || to < 0 || to >= next.length || to === from)
|
||||
return false;
|
||||
next.splice(to, 0, next.splice(from, 1)[0]);
|
||||
root.save(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// One trigger of the given kind, seeded so a mode is never saved in a
|
||||
// state that means nothing. Unknown kinds are refused rather than stored:
|
||||
// triggerActive() would read them as off, which is a mode that silently
|
||||
// never turns on.
|
||||
function seedTrigger(kind: string, fields: var): var {
|
||||
const extra = fields && typeof fields === "object" ? fields : {};
|
||||
switch (String(kind ?? "")) {
|
||||
case "schedule":
|
||||
return {
|
||||
kind: "schedule",
|
||||
start: String(extra.start ?? "22:00"),
|
||||
end: String(extra.end ?? "07:00"),
|
||||
days: Array.isArray(extra.days) ? extra.days.map(Number) : [0, 1, 2, 3, 4, 5, 6]
|
||||
};
|
||||
case "workspace":
|
||||
return { kind: "workspace", id: Number(extra.id ?? 1) };
|
||||
case "fullscreen":
|
||||
// `monitor` is optional and empty means any display. It is only
|
||||
// carried when handed in, so the editor's plain "a window is
|
||||
// fullscreen" stays plain.
|
||||
return extra.monitor === undefined
|
||||
? { kind: "fullscreen" }
|
||||
: { kind: "fullscreen", monitor: String(extra.monitor) };
|
||||
case "game":
|
||||
return { kind: "game" };
|
||||
case "manual":
|
||||
return { kind: "manual" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Replaces the mode's triggers with exactly one. Every shipped mode has a
|
||||
// single trigger and the editor only edits `triggers[0]`, so a list
|
||||
// hand-edited to hold several collapses to one the first time its kind is
|
||||
// changed here. The alternative -- editing one of several through a UI
|
||||
// that shows one -- is the more surprising of the two.
|
||||
function setTriggerKind(id: string, kind: string, fields: var): bool {
|
||||
const trigger = root.seedTrigger(kind, fields);
|
||||
if (!trigger || !root.hasMode(id))
|
||||
return false;
|
||||
root.update(id, { triggers: [trigger] });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── applying ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// What was true before a mode took over, so it can be put back. Recorded at
|
||||
|
||||
@@ -60,15 +60,26 @@ Singleton {
|
||||
|
||||
readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules)
|
||||
|
||||
// Every application that has a rule, with everything the settings list
|
||||
// needs to draw a row: the live-resolved name, the cached icon, when it
|
||||
// last said something, and the rule itself. Sorted by name, because the
|
||||
// page decides its own sections (recent / customized / all) from the
|
||||
// fields rather than from this order.
|
||||
readonly property var applications: {
|
||||
// byId()/heuristicLookup() do not make a binding by themselves. This
|
||||
// read updates persisted app labels once DesktopEntries finishes scan.
|
||||
const entries = DesktopEntries.applications.values;
|
||||
const remembered = root.rememberedApplications;
|
||||
return Object.keys(root.appRules).map(appId => ({
|
||||
id: appId,
|
||||
name: root.applicationLabel(appId, entries, remembered)
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
return Object.keys(root.appRules).map(appId => {
|
||||
const rule = root.appRule(appId);
|
||||
return {
|
||||
id: appId,
|
||||
name: root.applicationLabel(appId, entries, remembered),
|
||||
icon: root.applicationIcon(appId, rule.icon),
|
||||
lastSeenMs: rule.lastSeenMs,
|
||||
rule: rule
|
||||
};
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Arrival times, keyed by notification id — the protocol carries no
|
||||
@@ -86,8 +97,11 @@ Singleton {
|
||||
// Disturb is on (below). Critical urgency and an explicit expireTimeout
|
||||
// override policy; -1 ("server decides") falls back to it. 0 means "never
|
||||
// auto-expire" per spec.
|
||||
// Reads the effective urgency, so an application told to be treated as
|
||||
// critical also gets the critical banner duration rather than only the
|
||||
// critical look.
|
||||
function notificationTimeoutMs(notification: var): int {
|
||||
if (notification.urgency === NotificationUrgency.Critical)
|
||||
if (root.effectiveUrgency(notification) === NotificationUrgency.Critical)
|
||||
return Settings.notificationTimeoutCriticalMs;
|
||||
if (notification.expireTimeout === 0)
|
||||
return 0;
|
||||
@@ -103,37 +117,102 @@ Singleton {
|
||||
return desktopEntry || String(notification.appName ?? "").trim() || "Notifications";
|
||||
}
|
||||
|
||||
function applicationLabel(appId: string, entries: var, remembered: var): string {
|
||||
// The installed application behind a rule key, or null. An appId is either
|
||||
// a desktop entry id or the raw name an application announced itself with,
|
||||
// so both spellings and the heuristic are tried.
|
||||
function desktopEntryFor(appId: string): var {
|
||||
const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId;
|
||||
const entry = DesktopEntries.byId(appId)
|
||||
return DesktopEntries.byId(appId)
|
||||
|| DesktopEntries.byId(desktopId)
|
||||
|| DesktopEntries.heuristicLookup(appId)
|
||||
|| DesktopEntries.heuristicLookup(desktopId);
|
||||
return entry?.name || remembered[appId]?.name || appId;
|
||||
}
|
||||
|
||||
// Live first, cache second: an installed application is named by its
|
||||
// desktop entry every time, and the name stored on the rule only stands in
|
||||
// for one that is gone, sandboxed, or not scanned yet. The other way round
|
||||
// would freeze a name at the moment it was first heard from.
|
||||
function applicationLabel(appId: string, entries: var, remembered: var): string {
|
||||
return root.desktopEntryFor(appId)?.name || remembered[appId]?.name || appId;
|
||||
}
|
||||
|
||||
// Same rule for the icon: the desktop entry wins, the cached icon (an
|
||||
// entry icon, or the appIcon the notification carried) is the fallback.
|
||||
function applicationIcon(appId: string, cached: string): string {
|
||||
return root.desktopEntryFor(appId)?.icon || String(cached ?? "");
|
||||
}
|
||||
|
||||
// The durable per-application rule. Everything past `enabled` is optional
|
||||
// so rules written when this held only `enabled` still load, and anything
|
||||
// unrecognized -- including the lock-screen pair described below -- is
|
||||
// dropped rather than carried forward as if something read it.
|
||||
//
|
||||
// enabled off rejects the notification before anything else happens
|
||||
// sound false silences the bell for this application only
|
||||
// display "history" files it without a banner and without a bell
|
||||
// urgency "low"/"critical" override what the application claims
|
||||
// lastSeenMs when it last notified, stamped by rememberApplication
|
||||
// name, icon cached labels for the settings list (see above: fallback)
|
||||
function normalizedAppRule(rule: var): var {
|
||||
const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {};
|
||||
// Only `enabled` -- see the lock-screen note below for why the rule
|
||||
// model carries nothing else, and drops old fields on the way through.
|
||||
return { enabled: source.enabled !== false };
|
||||
const display = String(source.display ?? "");
|
||||
const urgency = String(source.urgency ?? "");
|
||||
const lastSeen = Number(source.lastSeenMs);
|
||||
return {
|
||||
enabled: source.enabled !== false,
|
||||
sound: source.sound !== false,
|
||||
display: display === "history" ? "history" : "banners",
|
||||
urgency: urgency === "low" || urgency === "critical" ? urgency : "auto",
|
||||
lastSeenMs: isFinite(lastSeen) && lastSeen > 0 ? Math.round(lastSeen) : 0,
|
||||
name: String(source.name ?? ""),
|
||||
icon: String(source.icon ?? "")
|
||||
};
|
||||
}
|
||||
|
||||
// A rule with no defaults touched. Everything the page calls "customized"
|
||||
// is a field that differs from this.
|
||||
readonly property var defaultAppRule: root.normalizedAppRule({})
|
||||
|
||||
function appRule(appId: string): var {
|
||||
return root.normalizedAppRule(root.appRules[appId]);
|
||||
}
|
||||
|
||||
// Has this application been given a rule that actually says something? The
|
||||
// bookkeeping fields do not count -- an application is "customized"
|
||||
// because of a decision somebody made about it, not because it notified.
|
||||
function isCustomized(appId: string): bool {
|
||||
const rule = root.appRule(appId);
|
||||
return rule.enabled !== true || rule.sound !== true
|
||||
|| rule.display !== "banners" || rule.urgency !== "auto";
|
||||
}
|
||||
|
||||
function setAppRule(appId: string, patch: var): bool {
|
||||
if (!appId)
|
||||
return false;
|
||||
|
||||
const current = root.appRule(appId);
|
||||
const changes = patch && typeof patch === "object" ? patch : {};
|
||||
const merged = Object.assign({}, root.appRule(appId));
|
||||
// Field by field rather than a blind merge, so a patch cannot smuggle
|
||||
// a value of the wrong type (or an unknown key) into the stored rule.
|
||||
if (changes.enabled !== undefined)
|
||||
merged.enabled = changes.enabled === true;
|
||||
if (changes.sound !== undefined)
|
||||
merged.sound = changes.sound === true;
|
||||
if (changes.display !== undefined)
|
||||
merged.display = String(changes.display);
|
||||
if (changes.urgency !== undefined)
|
||||
merged.urgency = String(changes.urgency);
|
||||
if (changes.lastSeenMs !== undefined)
|
||||
merged.lastSeenMs = Number(changes.lastSeenMs);
|
||||
if (changes.name !== undefined)
|
||||
merged.name = String(changes.name);
|
||||
if (changes.icon !== undefined)
|
||||
merged.icon = String(changes.icon);
|
||||
|
||||
const next = {};
|
||||
for (const knownAppId of Object.keys(root.appRules))
|
||||
next[knownAppId] = root.appRule(knownAppId);
|
||||
next[appId] = {
|
||||
enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true
|
||||
};
|
||||
next[appId] = root.normalizedAppRule(merged);
|
||||
|
||||
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
|
||||
root.fallbackAppRules = {};
|
||||
@@ -142,6 +221,49 @@ Singleton {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Delete the rule outright, rather than resetting its fields: an
|
||||
// application with no rule is treated as permissive, so forgetting one is
|
||||
// undone by its next notification, which writes a fresh default rule.
|
||||
// The session-only remembered name goes with it, otherwise the row would
|
||||
// linger under a name nothing is keeping.
|
||||
function forgetApp(appId: string): bool {
|
||||
if (!appId || root.appRules[appId] === undefined)
|
||||
return false;
|
||||
|
||||
const next = {};
|
||||
for (const knownAppId of Object.keys(root.appRules)) {
|
||||
if (knownAppId !== appId)
|
||||
next[knownAppId] = root.appRule(knownAppId);
|
||||
}
|
||||
|
||||
const remembered = Object.assign({}, root.rememberedApplications);
|
||||
delete remembered[appId];
|
||||
root.rememberedApplications = remembered;
|
||||
|
||||
// The session fallback is a whole-map replacement, so a deletion only
|
||||
// truly lands once the preference write succeeds -- which it does
|
||||
// whenever the schema key exists, i.e. always outside the transitional
|
||||
// build this fallback was written for.
|
||||
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
|
||||
root.fallbackAppRules = {};
|
||||
else
|
||||
root.fallbackAppRules = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
// What this notification counts as after the application's override: the
|
||||
// urgency the sender claimed unless a rule disagrees with it. Read by the
|
||||
// bell, the banner timeout, the Do Not Disturb breakthrough gate, and the
|
||||
// card's critical edge, so all four agree on one answer.
|
||||
function effectiveUrgency(notification: var): var {
|
||||
const rule = root.appRule(root.notificationAppId(notification));
|
||||
if (rule.urgency === "low")
|
||||
return NotificationUrgency.Low;
|
||||
if (rule.urgency === "critical")
|
||||
return NotificationUrgency.Critical;
|
||||
return notification.urgency;
|
||||
}
|
||||
|
||||
function rememberApplication(notification: var): string {
|
||||
const appId = root.notificationAppId(notification);
|
||||
const next = Object.assign({}, root.rememberedApplications);
|
||||
@@ -150,8 +272,22 @@ Singleton {
|
||||
};
|
||||
root.rememberedApplications = next;
|
||||
|
||||
if (root.appRules[appId] === undefined)
|
||||
root.setAppRule(appId, {});
|
||||
// Stamped alongside the labels the settings list falls back to when
|
||||
// the application is not installed here. Coalesced to one write per
|
||||
// minute per app: every setAppRule bumps the preferences revision and
|
||||
// restarts the settings.json write debounce, so stamping a chat
|
||||
// burst's every message would churn the whole preference store for a
|
||||
// timestamp nobody reads at that resolution.
|
||||
const rule = root.appRule(appId);
|
||||
const name = root.applicationLabel(appId, DesktopEntries.applications.values, next);
|
||||
const icon = root.applicationIcon(appId, String(notification.appIcon ?? ""));
|
||||
if (Date.now() - rule.lastSeenMs >= 60000 || rule.name !== name || rule.icon !== icon) {
|
||||
root.setAppRule(appId, {
|
||||
lastSeenMs: Date.now(),
|
||||
name: name,
|
||||
icon: icon
|
||||
});
|
||||
}
|
||||
return appId;
|
||||
}
|
||||
|
||||
@@ -211,7 +347,10 @@ Singleton {
|
||||
return;
|
||||
|
||||
const appId = root.rememberApplication(notification);
|
||||
if (!root.appRule(appId).enabled)
|
||||
// Read once: the same rule decides rejection, the bell, and
|
||||
// whether this is allowed to be a banner at all.
|
||||
const rule = root.appRule(appId);
|
||||
if (!rule.enabled)
|
||||
return;
|
||||
|
||||
// Without this the object is destroyed the instant this returns.
|
||||
@@ -228,10 +367,27 @@ Singleton {
|
||||
root.unreadCount += 1;
|
||||
}
|
||||
|
||||
// "History only" is the quiet filing cabinet: it was added to
|
||||
// history and counted as unread above, and that is all it gets --
|
||||
// no banner, and (never reaching playBell) no sound either.
|
||||
const historyOnly = rule.display === "history";
|
||||
|
||||
// The one exception to Do Not Disturb that is not a focus mode's,
|
||||
// and it is off unless somebody turned it on. It reads the
|
||||
// effective urgency, so a per-application override decides who
|
||||
// gets to claim "critical" rather than the sender alone.
|
||||
const breaksThrough = Settings.criticalBreaksThrough
|
||||
&& root.effectiveUrgency(notification) === NotificationUrgency.Critical;
|
||||
|
||||
// An exception belongs to the focus mode that is in force. A Do Not
|
||||
// Disturb switched on by hand has no exceptions and stays absolute,
|
||||
// because FocusModes.allows is false whenever no mode is active.
|
||||
if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) {
|
||||
if (historyOnly) {
|
||||
// Transient notifications are filed nowhere, so a hidden one
|
||||
// still needs the release its popup timeout would have given.
|
||||
if (notification.transient)
|
||||
root.scheduleTransientExpiry(notification);
|
||||
} else if (!root.doNotDisturb || FocusModes.allows(appId) || breaksThrough) {
|
||||
root.popups = [notification].concat(root.popups);
|
||||
root.playBell(notification);
|
||||
} else if (notification.transient) {
|
||||
@@ -260,9 +416,17 @@ Singleton {
|
||||
if (!SoundFeedback.eventSounds)
|
||||
return;
|
||||
|
||||
// The per-application sound switch. Narrower than turning the
|
||||
// application off: its notifications still arrive and still show, they
|
||||
// just stop making noise.
|
||||
if (!root.appRule(root.notificationAppId(notification)).sound)
|
||||
return;
|
||||
|
||||
// Low urgency is the "you did not need to know this" tier -- battery
|
||||
// reaching full, a sync completing. It stays silent by design.
|
||||
if (notification.urgency === NotificationUrgency.Low)
|
||||
// reaching full, a sync completing. It stays silent by design, and it
|
||||
// is the effective urgency, so "treat as low" is a way to keep an
|
||||
// application audible in principle but quiet in practice.
|
||||
if (root.effectiveUrgency(notification) === NotificationUrgency.Low)
|
||||
return;
|
||||
|
||||
// The freedesktop sound hints. This is the fix for the double chime:
|
||||
|
||||
@@ -24,8 +24,9 @@ Singleton {
|
||||
|
||||
// A category with no tabs is a leaf itself. A category with tabs is
|
||||
// addressed by its first available tab; its own page id is accepted as an
|
||||
// alias. Three category ids ("applications", "users", "privacy") double as
|
||||
// the id of their first tab, which resolves to the same place either way.
|
||||
// alias. Four category ids ("notifications", "applications", "users",
|
||||
// "privacy") double as the id of their first tab, which resolves to the
|
||||
// same place either way.
|
||||
readonly property var categories: [
|
||||
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [
|
||||
{ page: "home", label: "Overview" },
|
||||
@@ -42,7 +43,10 @@ Singleton {
|
||||
] },
|
||||
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
|
||||
{ page: "sound", label: "Sound", icon: "\u{F057E}", tabs: [] },
|
||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}", tabs: [] },
|
||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}", tabs: [
|
||||
{ page: "notifications", label: "Notifications" },
|
||||
{ page: "focus", label: "Focus" }
|
||||
] },
|
||||
{ page: "input", label: "Input", icon: "\u{F030C}", tabs: [
|
||||
{ page: "shortcuts", label: "Keyboard" },
|
||||
{ page: "mouse", label: "Mouse & Touchpad" },
|
||||
|
||||
@@ -42,7 +42,9 @@ Singleton {
|
||||
"wallpaper": "appearance",
|
||||
"lockAppearance": "appearance",
|
||||
"dock": "dock",
|
||||
"focus": "notifications",
|
||||
// Focus modes moved off the Notifications page onto their own tab, so
|
||||
// the group that owns them routes there now.
|
||||
"focus": "focus",
|
||||
"display": "displays",
|
||||
"nightLight": "displays",
|
||||
"idle": "power",
|
||||
@@ -158,6 +160,14 @@ Singleton {
|
||||
{ label: "Pinned applications", detail: "Reorder the dock by dragging, here or on the dock itself", page: "dock" },
|
||||
{ label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" },
|
||||
{ label: "Control Center sections", detail: "Choose what the panel offers", page: "control-center" },
|
||||
{ label: "Do Not Disturb", detail: "Hold banners back until you turn it off", page: "notifications" },
|
||||
{ label: "Quiet hours", detail: "The schedule the Sleep focus mode keeps", page: "notifications" },
|
||||
{ label: "Critical alerts break through", detail: "Let urgent notifications past Do Not Disturb", page: "notifications" },
|
||||
{ label: "Application notification rules", detail: "Which applications may notify you, and how", page: "notifications" },
|
||||
{ label: "Forget an app's notifications", detail: "Remove its rule; it returns on its next notification", page: "notifications" },
|
||||
{ label: "Per-app notification sound", detail: "Turn the chime off for one application", page: "notifications" },
|
||||
{ label: "Banners or history", detail: "Send one application straight to history without a popup", page: "notifications" },
|
||||
{ label: "Focus session duration", detail: "How long a focus session runs before it ends itself", page: "focus" },
|
||||
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
|
||||
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
|
||||
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
|
||||
|
||||
Reference in New Issue
Block a user