The mode data model shipped with an allow list and nothing that read it. The summary would say "2 apps may interrupt" while notification delivery never consulted the list and no editor could set it. That is the dead row this work has spent its time removing, introduced by the work itself. The banner gate consults the mode in force now, and the list can be edited from the applications that have actually sent a notification -- an exception for something that never notifies is not a choice worth offering. Exceptions belong to a mode. allowedApps is empty whenever no mode is active, so a Do Not Disturb switched on by hand stays absolute and nothing can leak into it. That scoping is asserted, not just written. Verifying this took three attempts, and the second was a real defect in the guard rather than in the code. The contract grep for FocusModes.allows matched the comment that explains it, so the check passed with the enforcement deleted. It matches the gate expression now. A guard a comment can satisfy is not a guard, and this is the third time prose has satisfied one here. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
505 lines
20 KiB
QML
505 lines
20 KiB
QML
import QtQuick
|
|
import qs.config
|
|
import qs.services
|
|
|
|
SettingsPage {
|
|
id: root
|
|
|
|
// Only one application is expanded at a time; the point is a card you
|
|
// can read, not twenty open at once.
|
|
property string expandedApp: ""
|
|
|
|
// Which focus mode is open for editing. One at a time, like the app rules.
|
|
property string expandedMode: ""
|
|
|
|
// 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 });
|
|
}
|
|
|
|
// 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 toggleDay(mode: var, day: int): void {
|
|
const schedule = (mode.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 });
|
|
}
|
|
|
|
title: "Notifications & Focus"
|
|
lede: "Control interruptions without losing useful history."
|
|
|
|
SettingsCard {
|
|
title: "Notifications"
|
|
|
|
SettingRow {
|
|
label: "Do Not Disturb"
|
|
detail: "Keep notifications in the center but suppress banners"
|
|
controlWidth: 48
|
|
|
|
SettingsToggle {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: Notifs.doNotDisturb
|
|
onToggled: value => Notifs.doNotDisturb = value
|
|
}
|
|
}
|
|
|
|
TextRow {
|
|
label: "Notification history"
|
|
detail: "Live notifications retained by the shell"
|
|
value: Notifs.history.length === 1 ? "1 item" : `${Notifs.history.length} items`
|
|
}
|
|
|
|
ActionRow {
|
|
label: "Clear notification history"
|
|
detail: "Dismiss every item currently in the notification center"
|
|
divider: false
|
|
action: "Clear all"
|
|
enabled: Notifs.history.length > 0
|
|
onTriggered: Notifs.dismissAll()
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Banner behavior"
|
|
|
|
SliderRow { setting: "notificationTimeoutMs" }
|
|
SliderRow {
|
|
setting: "notificationTimeoutCriticalMs"
|
|
zeroLabel: "Never"
|
|
}
|
|
SliderRow { setting: "notificationHistoryLimit" }
|
|
SliderRow { setting: "maxVisibleToasts"; divider: false }
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Application rules"
|
|
subtitle: "Apps appear here after they send a notification."
|
|
|
|
TextRow {
|
|
visible: Notifs.applications.length === 0
|
|
label: "No applications remembered yet"
|
|
detail: "Application controls will appear after the first notification arrives."
|
|
divider: false
|
|
}
|
|
|
|
Repeater {
|
|
model: Notifs.applications
|
|
|
|
Column {
|
|
id: appEntry
|
|
|
|
required property var modelData
|
|
|
|
readonly property var app: appEntry.modelData
|
|
readonly property var rule: Notifs.appRule(appEntry.app.id)
|
|
readonly property bool open: root.expandedApp === String(appEntry.app.id)
|
|
|
|
// What the two lock-screen switches add up to, so the common
|
|
// case -- reading rather than changing -- needs no interaction.
|
|
// Every app repeated both switch labels verbatim before this,
|
|
// which made twenty apps sixty rows of identical sentences.
|
|
readonly property string summary: {
|
|
if (!appEntry.rule.enabled)
|
|
return "Notifications off";
|
|
if (!appEntry.rule.showOnLockScreen)
|
|
return "On · hidden on the lock screen";
|
|
return appEntry.rule.showContentOnLockScreen
|
|
? "On · lock screen shows content"
|
|
: "On · lock screen shows the sender only";
|
|
}
|
|
|
|
width: parent.width
|
|
|
|
SettingRow {
|
|
width: parent.width
|
|
label: appEntry.app.name
|
|
// The identifier is only worth the space while the app is
|
|
// open, which is the only time it disambiguates anything.
|
|
detail: appEntry.open ? String(appEntry.app.id) : appEntry.summary
|
|
activatable: true
|
|
divider: !appEntry.open
|
|
controlWidth: 92
|
|
onActivated: root.expandedApp = appEntry.open ? "" : String(appEntry.app.id)
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 11
|
|
|
|
SettingsToggle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: appEntry.rule.enabled
|
|
onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value })
|
|
}
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: appEntry.open ? "\u25B4" : "\u25BE"
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
width: parent.width
|
|
visible: appEntry.open
|
|
label: "Show on lock screen"
|
|
detail: "Allow this app's notifications on the lock screen"
|
|
controlWidth: 48
|
|
|
|
SettingsToggle {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: appEntry.rule.showOnLockScreen
|
|
onToggled: value => Notifs.setAppRule(appEntry.app.id, { showOnLockScreen: value })
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
width: parent.width
|
|
visible: appEntry.open
|
|
label: "Show content on lock screen"
|
|
detail: "Show message details when this app is visible there"
|
|
divider: false
|
|
controlWidth: 48
|
|
// Meaningless unless the app reaches the lock screen at all.
|
|
opacity: appEntry.rule.showOnLockScreen ? 1 : 0.45
|
|
|
|
SettingsToggle {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
enabled: appEntry.rule.showOnLockScreen
|
|
checked: appEntry.rule.showContentOnLockScreen
|
|
onToggled: value => Notifs.setAppRule(appEntry.app.id, { showContentOnLockScreen: value })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Focus modes"
|
|
subtitle: FocusModes.active
|
|
? FocusModes.activeName + " is on because " + FocusModes.activeReason + "."
|
|
: "What quiets this machine, and what turns it on. The first mode whose condition is true wins, so order is priority."
|
|
|
|
Repeater {
|
|
model: FocusModes.modes
|
|
|
|
delegate: Column {
|
|
id: modeEntry
|
|
|
|
required property var modelData
|
|
|
|
readonly property var mode: modeEntry.modelData
|
|
readonly property string modeId: String(modeEntry.mode.id ?? "")
|
|
readonly property bool open: root.expandedMode === modeEntry.modeId
|
|
readonly property bool running: FocusModes.activeMode?.id === modeEntry.modeId
|
|
readonly property var schedule: (modeEntry.mode.triggers ?? [])
|
|
.find(trigger => trigger.kind === "schedule") ?? null
|
|
|
|
width: parent.width
|
|
|
|
SettingRow {
|
|
width: parent.width
|
|
label: String(modeEntry.mode.name ?? "")
|
|
detail: modeEntry.running
|
|
? "On now — " + FocusModes.activeReason
|
|
: FocusModes.summary(modeEntry.mode)
|
|
activatable: true
|
|
divider: !modeEntry.open
|
|
controlWidth: 92
|
|
onActivated: root.expandedMode = modeEntry.open ? "" : modeEntry.modeId
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 11
|
|
|
|
SettingsToggle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: modeEntry.mode.enabled === true
|
|
onToggled: value => FocusModes.setEnabled(modeEntry.modeId, value)
|
|
}
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modeEntry.open ? "\u25B4" : "\u25BE"
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
width: parent.width
|
|
visible: modeEntry.open
|
|
label: "Silence notifications"
|
|
detail: "Banners are held until the mode ends"
|
|
controlWidth: 48
|
|
|
|
SettingsToggle {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: modeEntry.mode.silence === true
|
|
onToggled: value => FocusModes.update(modeEntry.modeId, { silence: value })
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
width: parent.width
|
|
visible: modeEntry.open
|
|
label: "Keep the screen awake"
|
|
detail: "Caffeine, for as long as the mode is on"
|
|
controlWidth: 48
|
|
divider: modeEntry.schedule !== null
|
|
|
|
SettingsToggle {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: modeEntry.mode.keepAwake === true
|
|
onToggled: value => FocusModes.update(modeEntry.modeId, { keepAwake: value })
|
|
}
|
|
}
|
|
|
|
// Only modes that actually have a schedule get the schedule
|
|
// controls; a game trigger has no times to set.
|
|
TextFieldRow {
|
|
width: parent.width
|
|
visible: modeEntry.open && modeEntry.schedule !== null
|
|
label: "From"
|
|
detail: "24-hour, such as 23:30"
|
|
text: String(modeEntry.schedule?.start ?? "")
|
|
placeholder: "23:30"
|
|
onAccepted: value => root.reschedule(modeEntry.mode, { start: value })
|
|
}
|
|
|
|
TextFieldRow {
|
|
width: parent.width
|
|
visible: modeEntry.open && modeEntry.schedule !== null
|
|
label: "Until"
|
|
detail: "A time earlier than the start means the window crosses midnight"
|
|
text: String(modeEntry.schedule?.end ?? "")
|
|
placeholder: "07:00"
|
|
onAccepted: value => root.reschedule(modeEntry.mode, { end: value })
|
|
}
|
|
|
|
SettingRow {
|
|
width: parent.width
|
|
visible: modeEntry.open && modeEntry.mode.silence === true
|
|
label: "May interrupt"
|
|
detail: (modeEntry.mode.allow ?? []).length === 0
|
|
? "Nothing gets through while this mode is on"
|
|
: "Everything else is held until the mode ends"
|
|
activatable: true
|
|
controlWidth: 150
|
|
onActivated: root.expandedAllow =
|
|
root.expandedAllow === modeEntry.modeId ? "" : modeEntry.modeId
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 9
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: (modeEntry.mode.allow ?? []).length === 0
|
|
? "Nothing"
|
|
: (modeEntry.mode.allow ?? []).length + " apps"
|
|
color: Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
}
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.expandedAllow === modeEntry.modeId ? "\u25B4" : "\u25BE"
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drawn from the applications that have actually sent a
|
|
// notification, which is the same list the rules above use --
|
|
// an exception for something that never notifies is not a
|
|
// choice worth offering.
|
|
Repeater {
|
|
model: (modeEntry.open && root.expandedAllow === modeEntry.modeId)
|
|
? Notifs.applications
|
|
: []
|
|
|
|
delegate: SettingRow {
|
|
required property var modelData
|
|
width: parent.width
|
|
label: String(modelData.name ?? "")
|
|
detail: String(modelData.id ?? "")
|
|
controlWidth: 48
|
|
|
|
SettingsToggle {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: (modeEntry.mode.allow ?? []).indexOf(String(modelData.id)) >= 0
|
|
onToggled: value => root.toggleAllowed(modeEntry.mode, String(modelData.id), value)
|
|
}
|
|
}
|
|
}
|
|
|
|
TextRow {
|
|
width: parent.width
|
|
visible: modeEntry.open && root.expandedAllow === modeEntry.modeId
|
|
&& Notifs.applications.length === 0
|
|
label: "No applications yet"
|
|
detail: "They appear here once they have sent a notification."
|
|
value: ""
|
|
}
|
|
|
|
SettingRow {
|
|
width: parent.width
|
|
visible: modeEntry.open && modeEntry.schedule !== null
|
|
label: "On these days"
|
|
detail: "A window that crosses midnight belongs to the day it starts on"
|
|
controlWidth: 250
|
|
divider: false
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 5
|
|
|
|
Repeater {
|
|
model: [
|
|
{ value: 1, label: "M" }, { value: 2, label: "T" },
|
|
{ value: 3, label: "W" }, { value: 4, label: "T" },
|
|
{ value: 5, label: "F" }, { value: 6, label: "S" },
|
|
{ value: 0, label: "S" }
|
|
]
|
|
|
|
delegate: Rectangle {
|
|
id: dayPill
|
|
|
|
required property var modelData
|
|
|
|
readonly property bool on:
|
|
(modeEntry.schedule?.days ?? []).indexOf(dayPill.modelData.value) >= 0
|
|
|
|
width: 30
|
|
height: 28
|
|
radius: 8
|
|
color: dayPill.on ? Theme.alpha(Theme.accent, 0.22)
|
|
: Theme.alpha(Theme.fg, 0.06)
|
|
border.width: dayPill.on ? 1 : 0
|
|
border.color: Theme.alpha(Theme.accent, 0.5)
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: String(dayPill.modelData.label)
|
|
color: dayPill.on ? Theme.fg : Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
font.weight: dayPill.on ? Font.DemiBold : Font.Medium
|
|
}
|
|
|
|
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
|
TapHandler {
|
|
onTapped: root.toggleDay(modeEntry.mode, dayPill.modelData.value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Focus sessions"
|
|
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
|
|
|
SettingRow {
|
|
label: "Keep the screen awake"
|
|
detail: Caffeine.enabled
|
|
? "The display will not blank or lock while this is on"
|
|
: "Idle timings on Power & Lock apply normally"
|
|
controlWidth: 48
|
|
|
|
SettingsToggle {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: Caffeine.enabled
|
|
onToggled: value => Caffeine.enabled = value
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
id: durationRow
|
|
|
|
label: "Default duration"
|
|
detail: "Used by Super+Shift+F and Quick Settings"
|
|
controlWidth: 264
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 6
|
|
|
|
Repeater {
|
|
model: [25, 45, 60, 90]
|
|
|
|
SettingsButton {
|
|
required property int modelData
|
|
|
|
text: `${modelData}m`
|
|
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
|
|
onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingRow {
|
|
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
|
|
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
|
|
divider: false
|
|
controlWidth: 120
|
|
|
|
SettingsButton {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: FocusSession.active ? "Show controls" : "Start focus"
|
|
tone: FocusSession.active ? "normal" : "accent"
|
|
onClicked: FocusSession.reveal()
|
|
}
|
|
}
|
|
}
|
|
}
|