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

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 14:25:26 -04:00
parent 07db1068f1
commit d5b6e62515
21 changed files with 1405 additions and 453 deletions
@@ -294,14 +294,13 @@ Singleton {
}
]
},
// Grouped with the workspaces rather than with focus, because a group is
// where a setting is found rather than what it is about: the slider that
// sets this is a schema-bound row on WorkspacesPage. The focus group
// routes to Notifications, which is where focusModes renders.
// Set by the duration chips on the Focus tab, which is also where
// focusModes renders and where the focus group routes -- one editor,
// one page, so search and the docs point at the only place it exists.
{
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
unit: "min",
group: "workspaces",
group: "focus",
label: "Focus session length",
detail: "How long a focus session runs before it ends itself"
},
@@ -1490,6 +1489,20 @@ Singleton {
label: "Visible banners",
detail: "Maximum notification banners shown at once"
},
// The one hole anyone is allowed to punch in Do Not Disturb. Off by
// default, because Do Not Disturb that lets something through anyway is
// not the thing most people asked for -- and "critical" is the sender's
// word, not yours, so an application that calls everything critical
// would otherwise defeat the switch on its own say-so. Turned on, it
// shows a banner for critical notifications while a mode or a manual Do
// Not Disturb is silencing everything else; the per-application urgency
// override is how you decide which senders get to claim it.
{
key: "criticalBreaksThrough", type: "bool", def: false,
group: "notifications",
label: "Critical alerts break through",
detail: "Show critical notifications as banners even while Do Not Disturb is on"
},
// ── Sound ───────────────────────────────────────────────────────────
// The two audio preferences that are Panama's own. Everything else on
@@ -1618,8 +1631,17 @@ Singleton {
},
// ── Per-application notification rules ──────────────────────────────
// { "<appId>": { enabled } } -- lock-screen fields from older rules are
// dropped at normalization; hyprlock cannot render notifications.
// { "<appId>": { enabled, sound, display, urgency, lastSeenMs, name,
// icon } -- every field past `enabled` is optional, so rules written
// when this held only `enabled` still load. Lock-screen fields from
// older rules are dropped at normalization; hyprlock cannot render
// notifications.
//
// `name` and `icon` are a cache for the settings list, not the source:
// resolution stays live-first through DesktopEntries, and these only
// stand in for an application that is not installed (or not scanned
// yet). `lastSeenMs` is stamped on every notification and is what puts
// an application in the "Recent" section.
//
// Absent means "no rule", which is not the same as a rule that allows
// everything: a new application must be able to notify without needing
@@ -1630,7 +1652,7 @@ Singleton {
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
internal: true,
label: "Application notification rules",
detail: "Per-application notification and lock-screen visibility preferences"
detail: "Per-application notification sound, banner, and urgency preferences"
},
// ── Internal ────────────────────────────────────────────────────────
@@ -82,6 +82,9 @@ Singleton {
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
// The single exception to Do Not Disturb, read by the popup gate in
// services/Notifs.qml. Off means Do Not Disturb is absolute.
readonly property bool criticalBreaksThrough: DesktopPreferences.get("criticalBreaksThrough")
// ── Sound ───────────────────────────────────────────────────────────────
// Over-amplification is the clamp ceiling for output volume: off means 1.0,
@@ -56,6 +56,11 @@ Rectangle {
// Critical notifications get a red edge rather than a color wash, so the
// text contrast never changes.
//
// Read through Notifs.effectiveUrgency rather than off the notification, so
// an application the rules treat as critical is marked here too -- and one
// demoted to low is not. The bell, the popup timeout and the Do Not Disturb
// breakthrough all ask the same question the same way.
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
@@ -65,7 +70,7 @@ Rectangle {
radius: 1.5
border.width: 0
color: Theme.urgent
visible: root.notification.urgency === NotificationUrgency.Critical
visible: Notifs.effectiveUrgency(root.notification) === NotificationUrgency.Critical
}
readonly property var defaultAction: {
@@ -123,13 +123,13 @@ SettingsPage {
detail: {
const mode = FocusModes.modes.find(entry => entry.id === "gaming");
if (!mode)
return "Handled by a focus mode on Notifications & Focus.";
return "Handled by a focus mode on the Focus tab.";
return mode.enabled === true
? "Handled by the Gaming focus mode, which is on."
: "The Gaming focus mode is off, so notifications are not silenced.";
}
action: "Open Focus"
onTriggered: ShellState.settingsPage = "notifications"
onTriggered: ShellState.openSettings("focus")
}
ToggleRow {
@@ -1,62 +1,219 @@
// The notifications tab: what interrupts, how long it stays, and which
// applications are allowed to do it.
//
// The application list used to be every application that had ever notified,
// rendered in full, forever. On a machine that has been running for a year that
// is a wall nobody reads. It is now bounded: recent senders and applications
// whose rule has been changed sit up top, and everything else waits behind a
// searchable expander. Rows unfold in place into the rule itself.
//
// Focus modes moved to their own tab. What stays here is the one line saying a
// mode is currently quieting things, because that is the question this page is
// opened to answer.
import QtQuick
import qs.config
import qs.services
import qs.modules.clipboard
SettingsPage {
id: root
// Only one application is expanded at a time; the point is a card you
// can read, not twenty open at once.
// One application is unfolded at a time; the point is a card you can read,
// not twenty open at once.
property string expandedApp: ""
property bool allAppsOpen: false
// Which focus mode is open for editing. One at a time, like the app rules.
property string expandedMode: ""
readonly property int weekMs: 7 * 24 * 60 * 60 * 1000
// Which mode's exception list is open. Separate from expandedMode so the
// list does not unfold every time a mode is opened to change a switch.
property string expandedAllow: ""
function toggleAllowed(mode: var, appId: string, allowed: bool): void {
const current = Array.isArray(mode.allow) ? mode.allow.map(String) : [];
const at = current.indexOf(appId);
if (allowed && at < 0)
current.push(appId);
else if (!allowed && at >= 0)
current.splice(at, 1);
FocusModes.update(String(mode.id), { allow: current });
// Every rule write leaves through here, so a change made in a row goes down
// exactly the same path as one made anywhere else.
function editRule(appId: string, patch: var): void {
Notifs.setAppRule(appId, patch);
}
// Schedule edits go through the mode's trigger list rather than replacing
// it, so a mode that also has a game or fullscreen trigger keeps it.
function reschedule(mode: var, changes: var): void {
FocusModes.update(String(mode.id), {
triggers: (mode.triggers ?? []).map(trigger =>
trigger.kind === "schedule" ? Object.assign({}, trigger, changes) : trigger)
});
function forgetRule(appId: string): void {
if (root.expandedApp === appId)
root.expandedApp = "";
Notifs.forgetApp(appId);
}
function toggleDay(mode: var, day: int): void {
const schedule = (mode.triggers ?? []).find(trigger => trigger.kind === "schedule");
// A rule that differs from the defaults in any field. This is what earns an
// application a place above the fold even when it has not notified lately.
function customized(rule: var): bool {
return rule.enabled === false
|| rule.sound === false
|| String(rule.display ?? "banners") !== "banners"
|| String(rule.urgency ?? "auto") !== "auto";
}
// Relative rather than a timestamp: "three days ago" is the question being
// asked, and a clock time from last March answers a different one.
function lastSeenText(lastSeenMs: var): string {
const at = Number(lastSeenMs ?? 0);
if (!at)
return "";
const days = Math.floor((Date.now() - at) / 86400000);
if (days <= 0)
return "Today";
if (days === 1)
return "Yesterday";
if (days < 7)
return days + " days ago";
return Qt.formatDateTime(new Date(at), "d MMM");
}
// Spelled the same way the Focus tab spells it, so the two pages describe
// one schedule in one voice.
function daysText(days: var): string {
const list = Array.isArray(days) ? days.map(Number).slice().sort((a, b) => a - b) : [];
if (list.length === 0)
return "no days";
if (list.length === 7)
return "every day";
if (list.join(",") === "1,2,3,4,5")
return "weekdays";
if (list.join(",") === "0,6")
return "weekends";
const names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
return list.map(day => names[day]).join(", ");
}
// Quiet hours are the Sleep mode's schedule. Rather than inventing a second
// place to set them, the row states what Sleep is actually doing and hands
// over to the tab that owns it.
function quietHoursDetail(): string {
const sleep = FocusModes.modes.find(mode => String(mode.id) === "sleep");
if (!sleep)
return "There is no Sleep focus mode, so nothing is scheduled";
const schedule = (sleep.triggers ?? []).find(trigger => trigger.kind === "schedule");
if (!schedule)
return;
const days = Array.isArray(schedule.days) ? schedule.days.slice() : [];
const at = days.indexOf(day);
if (at >= 0)
days.splice(at, 1);
else
days.push(day);
days.sort((a, b) => a - b);
root.reschedule(mode, { days: days });
return "The Sleep focus mode has no schedule, so quiet hours are not scheduled";
const when = String(schedule.start ?? "") + " to " + String(schedule.end ?? "")
+ ", " + root.daysText(schedule.days);
return sleep.enabled === true
? "Scheduled through the Sleep focus mode — " + when
: "The Sleep focus mode is off; it would run " + when;
}
title: "Notifications & Focus"
readonly property var recentApps: {
const now = Date.now();
return Notifs.applications
.filter(app => Number(app.lastSeenMs ?? 0) > 0
&& now - Number(app.lastSeenMs) < root.weekMs)
.sort((a, b) => Number(b.lastSeenMs) - Number(a.lastSeenMs));
}
// Sections are partitioned by id rather than by object identity: these are
// plain objects rebuilt by a binding, and an application appearing in two
// sections at once is the failure that would cause.
readonly property var recentIds: root.recentApps.map(app => String(app.id))
readonly property var customizedApps: Notifs.applications.filter(app =>
root.recentIds.indexOf(String(app.id)) < 0 && root.customized(app.rule))
readonly property var customizedIds: root.customizedApps.map(app => String(app.id))
// Everything not already on screen. Notifs.applications arrives sorted by
// name, so this stays alphabetical for free.
readonly property var otherApps: Notifs.applications.filter(app =>
root.recentIds.indexOf(String(app.id)) < 0
&& root.customizedIds.indexOf(String(app.id)) < 0)
// With nothing typed the expander holds what is not shown above. A typed
// query searches the whole universe instead -- a search box that cannot
// find an application you can see is worse than a duplicated row.
readonly property var expanderApps: {
const needle = appSearch.text.trim().toLowerCase();
if (needle === "")
return root.otherApps;
return Notifs.applications.filter(app =>
(String(app.name) + " " + String(app.id)).toLowerCase().indexOf(needle) >= 0);
}
readonly property string allowSummary: {
const allow = FocusModes.allowedApps;
if (allow.length === 0)
return "nothing may interrupt";
const names = allow.map(id => {
const app = Notifs.applications.find(entry => String(entry.id) === id);
return app ? String(app.name) : id;
});
if (names.length <= 2)
return names.join(" and ") + " may interrupt";
return names.slice(0, 2).join(", ") + " and " + (names.length - 2)
+ " more may interrupt";
}
title: "Notifications"
lede: "Control interruptions without losing useful history."
// Why the machine is quiet, at the top, where the question is asked.
Rectangle {
id: activeBanner
width: parent.width
visible: FocusModes.activeMode !== null
height: visible ? Math.max(58, bannerCopy.implicitHeight + 24) : 0
radius: Theme.cardRadius + 1
color: Theme.alpha(Theme.ok, 0.08)
border.width: 1
border.color: Theme.alpha(Theme.ok, 0.3)
Column {
id: bannerCopy
anchors.left: parent.left
anchors.leftMargin: 15
anchors.right: bannerButton.left
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: FocusModes.activeMode?.silence === true
? FocusModes.activeName + " is quieting notifications"
: FocusModes.activeName + " is on"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: {
const why = "Because " + FocusModes.activeReason;
return FocusModes.activeMode?.silence === true
? why + " · " + root.allowSummary
: why + " · notifications are unaffected";
}
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
SettingsButton {
id: bannerButton
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "Open Focus"
onClicked: ShellState.openSettings("focus")
}
}
SettingsCard {
title: "Notifications"
title: "Quiet"
SettingRow {
label: "Do Not Disturb"
detail: "Keep notifications in the center but suppress banners"
detail: "Banners stop; everything still lands in the notification center"
controlWidth: 48
SettingsToggle {
@@ -67,37 +224,43 @@ SettingsPage {
}
}
TextRow {
label: "Notification history"
detail: "Live notifications retained by the shell"
value: Notifs.history.length === 1 ? "1 item" : `${Notifs.history.length} items`
}
ToggleRow { setting: "criticalBreaksThrough" }
ActionRow {
label: "Clear notification history"
detail: "Dismiss every item currently in the notification center"
label: "Quiet hours"
detail: root.quietHoursDetail()
action: "Open Focus"
divider: false
action: "Clear all"
enabled: Notifs.history.length > 0
onTriggered: Notifs.dismissAll()
onTriggered: ShellState.openSettings("focus")
}
}
SettingsCard {
title: "Banner behavior"
title: "Banners & history"
SliderRow { setting: "notificationTimeoutMs" }
SliderRow {
setting: "notificationTimeoutCriticalMs"
zeroLabel: "Never"
}
SliderRow { setting: "maxVisibleToasts" }
SliderRow { setting: "notificationHistoryLimit" }
SliderRow { setting: "maxVisibleToasts"; divider: false }
ActionRow {
label: "Notification history"
detail: Notifs.history.length === 1
? "1 kept now · the oldest are dropped past the limit"
: `${Notifs.history.length} kept now · the oldest are dropped past the limit`
action: "Clear"
enabled: Notifs.history.length > 0
divider: false
onTriggered: Notifs.dismissAll()
}
}
SettingsCard {
title: "Application rules"
subtitle: "Apps appear here after they send a notification."
title: "Applications"
subtitle: "Apps appear after their first notification. Recent senders and apps you have customized stay up top; the rest wait in All apps."
TextRow {
visible: Notifs.applications.length === 0
@@ -106,332 +269,155 @@ SettingsPage {
divider: false
}
Repeater {
model: Notifs.applications
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
+185 -21
View File
@@ -60,15 +60,26 @@ Singleton {
readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules)
// Every application that has a rule, with everything the settings list
// needs to draw a row: the live-resolved name, the cached icon, when it
// last said something, and the rule itself. Sorted by name, because the
// page decides its own sections (recent / customized / all) from the
// fields rather than from this order.
readonly property var applications: {
// byId()/heuristicLookup() do not make a binding by themselves. This
// read updates persisted app labels once DesktopEntries finishes scan.
const entries = DesktopEntries.applications.values;
const remembered = root.rememberedApplications;
return Object.keys(root.appRules).map(appId => ({
id: appId,
name: root.applicationLabel(appId, entries, remembered)
})).sort((a, b) => a.name.localeCompare(b.name));
return Object.keys(root.appRules).map(appId => {
const rule = root.appRule(appId);
return {
id: appId,
name: root.applicationLabel(appId, entries, remembered),
icon: root.applicationIcon(appId, rule.icon),
lastSeenMs: rule.lastSeenMs,
rule: rule
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
// Arrival times, keyed by notification id — the protocol carries no
@@ -86,8 +97,11 @@ Singleton {
// Disturb is on (below). Critical urgency and an explicit expireTimeout
// override policy; -1 ("server decides") falls back to it. 0 means "never
// auto-expire" per spec.
// Reads the effective urgency, so an application told to be treated as
// critical also gets the critical banner duration rather than only the
// critical look.
function notificationTimeoutMs(notification: var): int {
if (notification.urgency === NotificationUrgency.Critical)
if (root.effectiveUrgency(notification) === NotificationUrgency.Critical)
return Settings.notificationTimeoutCriticalMs;
if (notification.expireTimeout === 0)
return 0;
@@ -103,37 +117,102 @@ Singleton {
return desktopEntry || String(notification.appName ?? "").trim() || "Notifications";
}
function applicationLabel(appId: string, entries: var, remembered: var): string {
// The installed application behind a rule key, or null. An appId is either
// a desktop entry id or the raw name an application announced itself with,
// so both spellings and the heuristic are tried.
function desktopEntryFor(appId: string): var {
const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId;
const entry = DesktopEntries.byId(appId)
return DesktopEntries.byId(appId)
|| DesktopEntries.byId(desktopId)
|| DesktopEntries.heuristicLookup(appId)
|| DesktopEntries.heuristicLookup(desktopId);
return entry?.name || remembered[appId]?.name || appId;
}
// Live first, cache second: an installed application is named by its
// desktop entry every time, and the name stored on the rule only stands in
// for one that is gone, sandboxed, or not scanned yet. The other way round
// would freeze a name at the moment it was first heard from.
function applicationLabel(appId: string, entries: var, remembered: var): string {
return root.desktopEntryFor(appId)?.name || remembered[appId]?.name || appId;
}
// Same rule for the icon: the desktop entry wins, the cached icon (an
// entry icon, or the appIcon the notification carried) is the fallback.
function applicationIcon(appId: string, cached: string): string {
return root.desktopEntryFor(appId)?.icon || String(cached ?? "");
}
// The durable per-application rule. Everything past `enabled` is optional
// so rules written when this held only `enabled` still load, and anything
// unrecognized -- including the lock-screen pair described below -- is
// dropped rather than carried forward as if something read it.
//
// enabled off rejects the notification before anything else happens
// sound false silences the bell for this application only
// display "history" files it without a banner and without a bell
// urgency "low"/"critical" override what the application claims
// lastSeenMs when it last notified, stamped by rememberApplication
// name, icon cached labels for the settings list (see above: fallback)
function normalizedAppRule(rule: var): var {
const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {};
// Only `enabled` -- see the lock-screen note below for why the rule
// model carries nothing else, and drops old fields on the way through.
return { enabled: source.enabled !== false };
const display = String(source.display ?? "");
const urgency = String(source.urgency ?? "");
const lastSeen = Number(source.lastSeenMs);
return {
enabled: source.enabled !== false,
sound: source.sound !== false,
display: display === "history" ? "history" : "banners",
urgency: urgency === "low" || urgency === "critical" ? urgency : "auto",
lastSeenMs: isFinite(lastSeen) && lastSeen > 0 ? Math.round(lastSeen) : 0,
name: String(source.name ?? ""),
icon: String(source.icon ?? "")
};
}
// A rule with no defaults touched. Everything the page calls "customized"
// is a field that differs from this.
readonly property var defaultAppRule: root.normalizedAppRule({})
function appRule(appId: string): var {
return root.normalizedAppRule(root.appRules[appId]);
}
// Has this application been given a rule that actually says something? The
// bookkeeping fields do not count -- an application is "customized"
// because of a decision somebody made about it, not because it notified.
function isCustomized(appId: string): bool {
const rule = root.appRule(appId);
return rule.enabled !== true || rule.sound !== true
|| rule.display !== "banners" || rule.urgency !== "auto";
}
function setAppRule(appId: string, patch: var): bool {
if (!appId)
return false;
const current = root.appRule(appId);
const changes = patch && typeof patch === "object" ? patch : {};
const merged = Object.assign({}, root.appRule(appId));
// Field by field rather than a blind merge, so a patch cannot smuggle
// a value of the wrong type (or an unknown key) into the stored rule.
if (changes.enabled !== undefined)
merged.enabled = changes.enabled === true;
if (changes.sound !== undefined)
merged.sound = changes.sound === true;
if (changes.display !== undefined)
merged.display = String(changes.display);
if (changes.urgency !== undefined)
merged.urgency = String(changes.urgency);
if (changes.lastSeenMs !== undefined)
merged.lastSeenMs = Number(changes.lastSeenMs);
if (changes.name !== undefined)
merged.name = String(changes.name);
if (changes.icon !== undefined)
merged.icon = String(changes.icon);
const next = {};
for (const knownAppId of Object.keys(root.appRules))
next[knownAppId] = root.appRule(knownAppId);
next[appId] = {
enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true
};
next[appId] = root.normalizedAppRule(merged);
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
root.fallbackAppRules = {};
@@ -142,6 +221,49 @@ Singleton {
return true;
}
// Delete the rule outright, rather than resetting its fields: an
// application with no rule is treated as permissive, so forgetting one is
// undone by its next notification, which writes a fresh default rule.
// The session-only remembered name goes with it, otherwise the row would
// linger under a name nothing is keeping.
function forgetApp(appId: string): bool {
if (!appId || root.appRules[appId] === undefined)
return false;
const next = {};
for (const knownAppId of Object.keys(root.appRules)) {
if (knownAppId !== appId)
next[knownAppId] = root.appRule(knownAppId);
}
const remembered = Object.assign({}, root.rememberedApplications);
delete remembered[appId];
root.rememberedApplications = remembered;
// The session fallback is a whole-map replacement, so a deletion only
// truly lands once the preference write succeeds -- which it does
// whenever the schema key exists, i.e. always outside the transitional
// build this fallback was written for.
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
root.fallbackAppRules = {};
else
root.fallbackAppRules = next;
return true;
}
// What this notification counts as after the application's override: the
// urgency the sender claimed unless a rule disagrees with it. Read by the
// bell, the banner timeout, the Do Not Disturb breakthrough gate, and the
// card's critical edge, so all four agree on one answer.
function effectiveUrgency(notification: var): var {
const rule = root.appRule(root.notificationAppId(notification));
if (rule.urgency === "low")
return NotificationUrgency.Low;
if (rule.urgency === "critical")
return NotificationUrgency.Critical;
return notification.urgency;
}
function rememberApplication(notification: var): string {
const appId = root.notificationAppId(notification);
const next = Object.assign({}, root.rememberedApplications);
@@ -150,8 +272,22 @@ Singleton {
};
root.rememberedApplications = next;
if (root.appRules[appId] === undefined)
root.setAppRule(appId, {});
// Stamped alongside the labels the settings list falls back to when
// the application is not installed here. Coalesced to one write per
// minute per app: every setAppRule bumps the preferences revision and
// restarts the settings.json write debounce, so stamping a chat
// burst's every message would churn the whole preference store for a
// timestamp nobody reads at that resolution.
const rule = root.appRule(appId);
const name = root.applicationLabel(appId, DesktopEntries.applications.values, next);
const icon = root.applicationIcon(appId, String(notification.appIcon ?? ""));
if (Date.now() - rule.lastSeenMs >= 60000 || rule.name !== name || rule.icon !== icon) {
root.setAppRule(appId, {
lastSeenMs: Date.now(),
name: name,
icon: icon
});
}
return appId;
}
@@ -211,7 +347,10 @@ Singleton {
return;
const appId = root.rememberApplication(notification);
if (!root.appRule(appId).enabled)
// Read once: the same rule decides rejection, the bell, and
// whether this is allowed to be a banner at all.
const rule = root.appRule(appId);
if (!rule.enabled)
return;
// Without this the object is destroyed the instant this returns.
@@ -228,10 +367,27 @@ Singleton {
root.unreadCount += 1;
}
// "History only" is the quiet filing cabinet: it was added to
// history and counted as unread above, and that is all it gets --
// no banner, and (never reaching playBell) no sound either.
const historyOnly = rule.display === "history";
// The one exception to Do Not Disturb that is not a focus mode's,
// and it is off unless somebody turned it on. It reads the
// effective urgency, so a per-application override decides who
// gets to claim "critical" rather than the sender alone.
const breaksThrough = Settings.criticalBreaksThrough
&& root.effectiveUrgency(notification) === NotificationUrgency.Critical;
// An exception belongs to the focus mode that is in force. A Do Not
// Disturb switched on by hand has no exceptions and stays absolute,
// because FocusModes.allows is false whenever no mode is active.
if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) {
if (historyOnly) {
// Transient notifications are filed nowhere, so a hidden one
// still needs the release its popup timeout would have given.
if (notification.transient)
root.scheduleTransientExpiry(notification);
} else if (!root.doNotDisturb || FocusModes.allows(appId) || breaksThrough) {
root.popups = [notification].concat(root.popups);
root.playBell(notification);
} else if (notification.transient) {
@@ -260,9 +416,17 @@ Singleton {
if (!SoundFeedback.eventSounds)
return;
// The per-application sound switch. Narrower than turning the
// application off: its notifications still arrive and still show, they
// just stop making noise.
if (!root.appRule(root.notificationAppId(notification)).sound)
return;
// Low urgency is the "you did not need to know this" tier -- battery
// reaching full, a sync completing. It stays silent by design.
if (notification.urgency === NotificationUrgency.Low)
// reaching full, a sync completing. It stays silent by design, and it
// is the effective urgency, so "treat as low" is a way to keep an
// application audible in principle but quiet in practice.
if (root.effectiveUrgency(notification) === NotificationUrgency.Low)
return;
// The freedesktop sound hints. This is the fix for the double chime:
@@ -24,8 +24,9 @@ Singleton {
// A category with no tabs is a leaf itself. A category with tabs is
// addressed by its first available tab; its own page id is accepted as an
// alias. Three category ids ("applications", "users", "privacy") double as
// the id of their first tab, which resolves to the same place either way.
// alias. Four category ids ("notifications", "applications", "users",
// "privacy") double as the id of their first tab, which resolves to the
// same place either way.
readonly property var categories: [
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [
{ page: "home", label: "Overview" },
@@ -42,7 +43,10 @@ Singleton {
] },
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
{ page: "sound", label: "Sound", icon: "\u{F057E}", tabs: [] },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}", tabs: [] },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}", tabs: [
{ page: "notifications", label: "Notifications" },
{ page: "focus", label: "Focus" }
] },
{ page: "input", label: "Input", icon: "\u{F030C}", tabs: [
{ page: "shortcuts", label: "Keyboard" },
{ page: "mouse", label: "Mouse & Touchpad" },
@@ -42,7 +42,9 @@ Singleton {
"wallpaper": "appearance",
"lockAppearance": "appearance",
"dock": "dock",
"focus": "notifications",
// Focus modes moved off the Notifications page onto their own tab, so
// the group that owns them routes there now.
"focus": "focus",
"display": "displays",
"nightLight": "displays",
"idle": "power",
@@ -158,6 +160,14 @@ Singleton {
{ label: "Pinned applications", detail: "Reorder the dock by dragging, here or on the dock itself", page: "dock" },
{ label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" },
{ label: "Control Center sections", detail: "Choose what the panel offers", page: "control-center" },
{ label: "Do Not Disturb", detail: "Hold banners back until you turn it off", page: "notifications" },
{ label: "Quiet hours", detail: "The schedule the Sleep focus mode keeps", page: "notifications" },
{ label: "Critical alerts break through", detail: "Let urgent notifications past Do Not Disturb", page: "notifications" },
{ label: "Application notification rules", detail: "Which applications may notify you, and how", page: "notifications" },
{ label: "Forget an app's notifications", detail: "Remove its rule; it returns on its next notification", page: "notifications" },
{ label: "Per-app notification sound", detail: "Turn the chime off for one application", page: "notifications" },
{ label: "Banners or history", detail: "Send one application straight to history without a popup", page: "notifications" },
{ label: "Focus session duration", detail: "How long a focus session runs before it ends itself", page: "focus" },
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Notifications & Focus
# @vicinae.title Settings: Notifications
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Notifications & Focus in Settings.
# @vicinae.keywords ["settings", "focus modes", "notification duration", "critical notification duration", "notification history", "visible banners"]
# @vicinae.description Open Notifications in Settings.
# @vicinae.keywords ["settings", "notification duration", "critical notification duration", "notification history", "visible banners", "critical alerts break through", "do not disturb", "quiet hours", "application notification rules", "forget an app's notifications", "per-app notification sound", "banners or history"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page notifications
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Workspaces in Settings.
# @vicinae.keywords ["settings", "focus session length", "switch back and forth", "wrap around at the ends", "let applications take focus", "hide the terminal that launched a window", "pointer changes active display"]
# @vicinae.keywords ["settings", "switch back and forth", "wrap around at the ends", "let applications take focus", "hide the terminal that launched a window", "pointer changes active display"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page workspaces