Give focus modes conditions rather than alarms, and let Gaming hand over

A mode is on because something is true right now: a game is running, a window is
fullscreen on a given display, a workspace is focused, the clock is inside a
window. That is asked again rather than fired once, and it is the whole reason
schedules could be included here without the usual failure modes. A machine
asleep at 23:30, rebooted at 02:00, or opened at 08:00 into a window that has
already passed all reach the right answer by being asked again; an alarm gets
all three wrong.

The midnight-crossing rule is the part worth being careful about: a window
belongs to the day it STARTS on, so a Friday-only 23:30-07:00 covers Saturday
morning and must not cover Saturday night. That arithmetic was tested as pure
logic before anything was built on it, including every malformed input failing
closed -- silencing someone because a time string was wrong is the worst way
this could fail.

This does not take over the manual timed session. FocusSession already owns
that, with its capsule, shortcut, Quick Settings entry and contracts, so modes
defer entirely while one runs. Two writers of Do Not Disturb would each restore
whatever the other happened to leave behind.

Gaming hands over rather than being duplicated. The hook was silencing
notifications itself, which would have made exactly those two owners -- and
Gaming.active only polls while its settings page is open, so a mode could not
have seen a game reliably in any case. The hook reports the game over IPC now
and the mode decides what that means, the Gaming page points at it, and
gamingSilenceNotifications is retired from the schema, since a setting nothing
reads is the dead row this work keeps removing.

Sleep ships disabled. A desktop that starts silencing someone on first boot has
overstepped, whatever the default hour.

Three contracts moved with it. gaming-contract asserted the hook uses setDnd,
which was right before and wrong now; the shell-side assertions that setDnd and
dndState exist stay, because a toggle would flip an already-silent machine back
on. The new contract is proven to fail by breaking the midnight rule and by
letting modes run alongside a manual session.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-20 02:06:50 -04:00
parent 32fab59d24
commit 6dc606b872
11 changed files with 650 additions and 25 deletions
@@ -9,6 +9,32 @@ SettingsPage {
// 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: ""
// 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."
@@ -164,6 +190,172 @@ SettingsPage {
}
}
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.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."