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:
@@ -113,6 +113,41 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── Focus ───────────────────────────────────────────────────────────
|
||||
// ── Focus modes ─────────────────────────────────────────────────────
|
||||
// A "json" value: named modes, each with what turns it on and what it
|
||||
// does. Triggers rather than a scheduler -- a mode is on because a
|
||||
// condition is true right now, which is re-evaluated rather than fired
|
||||
// once. A schedule is one of those conditions ("is now inside this
|
||||
// window?"), which is why suspend, a reboot mid-window, and a lid
|
||||
// opened after the start time all behave correctly without special
|
||||
// cases: there is no alarm to have missed.
|
||||
//
|
||||
// Gaming ships enabled because the behaviour already existed as
|
||||
// gamingSilenceNotifications; Sleep ships disabled, because a desktop
|
||||
// that starts silencing someone on first boot has overstepped.
|
||||
{
|
||||
key: "focusModes", type: "json", group: "focus",
|
||||
label: "Focus modes",
|
||||
detail: "What quiets this machine, and what turns it on",
|
||||
def: [
|
||||
{
|
||||
id: "deep-work", name: "Deep work", enabled: true,
|
||||
triggers: [{ kind: "manual" }],
|
||||
durationMinutes: 45, silence: true, keepAwake: true, allow: []
|
||||
},
|
||||
{
|
||||
id: "gaming", name: "Gaming", enabled: true,
|
||||
triggers: [{ kind: "game" }],
|
||||
durationMinutes: 0, silence: true, keepAwake: true, allow: []
|
||||
},
|
||||
{
|
||||
id: "sleep", name: "Sleep", enabled: false,
|
||||
triggers: [{ kind: "schedule", start: "23:30", end: "07:00",
|
||||
days: [0, 1, 2, 3, 4, 5, 6] }],
|
||||
durationMinutes: 0, silence: true, keepAwake: false, allow: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||
unit: "min",
|
||||
@@ -740,11 +775,6 @@ Singleton {
|
||||
label: "Use the performance power profile",
|
||||
detail: "Switches while a game runs and switches back when it exits"
|
||||
},
|
||||
{
|
||||
key: "gamingSilenceNotifications", type: "bool", def: true, group: "gaming",
|
||||
label: "Silence notifications",
|
||||
detail: "Do Not Disturb for the duration, so nothing steals focus mid-game. A Do Not Disturb you set yourself is left alone."
|
||||
},
|
||||
{
|
||||
key: "gamingNotifyOnStart", type: "bool", def: false, group: "gaming",
|
||||
label: "Say when Game Mode engages",
|
||||
|
||||
@@ -114,9 +114,22 @@ SettingsPage {
|
||||
setting: "gamingPerformanceProfile"
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
// Silencing while a game runs is a focus mode with a trigger, and it
|
||||
// now lives with the others -- so "something silenced my notifications"
|
||||
// has one answer rather than two places to look.
|
||||
ActionRow {
|
||||
visible: Gaming.gameMode?.hooksInstalled === true
|
||||
setting: "gamingSilenceNotifications"
|
||||
label: "Silence notifications"
|
||||
detail: {
|
||||
const mode = FocusModes.modes.find(entry => entry.id === "gaming");
|
||||
if (!mode)
|
||||
return "Handled by a focus mode on Notifications & Focus.";
|
||||
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"
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -278,12 +278,12 @@ def hook(phase: str) -> None:
|
||||
before["profile"] = ""
|
||||
run([str(scripts / "panama-power-profile"), "set", "performance"], timeout=15)
|
||||
|
||||
if settings.get("gamingSilenceNotifications", True) and shutil.which("qs"):
|
||||
was_silent = run(["qs", "ipc", "call", "notifications", "dndState"],
|
||||
timeout=10).stdout.strip() == "true"
|
||||
before["silenced"] = was_silent
|
||||
if not was_silent:
|
||||
run(["qs", "ipc", "call", "notifications", "setDnd", "true"], timeout=10)
|
||||
# Reported, not acted on. Silencing is a focus mode's job now, and two
|
||||
# things writing Do Not Disturb would each restore whatever the other
|
||||
# happened to leave behind. The hook knows a game started; what that
|
||||
# should mean is decided in one place.
|
||||
if shutil.which("qs"):
|
||||
run(["qs", "ipc", "call", "focus", "gameStarted"], timeout=10)
|
||||
|
||||
try:
|
||||
state_path.write_text(json.dumps(before), encoding="utf-8")
|
||||
@@ -292,7 +292,7 @@ def hook(phase: str) -> None:
|
||||
|
||||
if settings.get("gamingNotifyOnStart", False) and shutil.which("notify-send"):
|
||||
run(["notify-send", "-a", "Panama", "Game Mode",
|
||||
"Performance profile engaged, notifications silenced."], timeout=10)
|
||||
"Performance profile engaged."], timeout=10)
|
||||
return
|
||||
|
||||
# end
|
||||
@@ -302,9 +302,8 @@ def hook(phase: str) -> None:
|
||||
before = {}
|
||||
if before.get("profile"):
|
||||
run([str(scripts / "panama-power-profile"), "set", before["profile"]], timeout=15)
|
||||
# Only un-silence if this turned it on.
|
||||
if before.get("silenced") is False and shutil.which("qs"):
|
||||
run(["qs", "ipc", "call", "notifications", "setDnd", "false"], timeout=10)
|
||||
if shutil.which("qs"):
|
||||
run(["qs", "ipc", "call", "focus", "gameEnded"], timeout=10)
|
||||
try:
|
||||
state_path.unlink()
|
||||
except OSError:
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
pragma Singleton
|
||||
|
||||
// Named focus modes, turned on by conditions rather than by alarms.
|
||||
//
|
||||
// A mode is active 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 re-evaluated continuously rather than fired once,
|
||||
// which is the whole reason schedules here do not have the usual failure modes:
|
||||
// there is no alarm to have missed. A machine asleep at 23:30, rebooted at
|
||||
// 02:00, or opened at 08:00 into a window that has already passed all arrive at
|
||||
// the correct answer by simply asking again.
|
||||
//
|
||||
// This deliberately does not own the manual timed session. FocusSession already
|
||||
// does that, with its own capsule, shortcut, Quick Settings entry and contracts,
|
||||
// and two things writing Do Not Disturb would fight. Modes whose only trigger is
|
||||
// "manual" are started through FocusSession; everything automatic lives here,
|
||||
// and defers entirely while a manual session is running.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var modes: {
|
||||
const stored = DesktopPreferences.get("focusModes");
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
|
||||
// Modes something could switch on without being asked.
|
||||
readonly property var automatic: root.modes.filter(mode =>
|
||||
mode.enabled === true && (mode.triggers ?? []).some(trigger => trigger.kind !== "manual"))
|
||||
|
||||
// Re-read on a timer only because one trigger kind depends on the clock.
|
||||
// Everything else is driven by change signals; this is what makes a schedule
|
||||
// a condition rather than an alarm.
|
||||
property double nowMs: Date.now()
|
||||
|
||||
// Set by the gamemode start/end hooks over IPC. The hook is the only thing
|
||||
// that reliably knows, and it is already talking to the shell.
|
||||
property bool gameRunning: false
|
||||
|
||||
// The mode in force, or null. First match wins, so the order in the list is
|
||||
// the priority: a person who wants Gaming to beat Sleep moves it up.
|
||||
readonly property var activeMode: {
|
||||
// A manual session owns Do Not Disturb while it runs. Evaluating on top
|
||||
// of it would mean two owners for one piece of state and a restore that
|
||||
// puts back whatever the loser happened to see.
|
||||
if (FocusSession.active)
|
||||
return null;
|
||||
for (const mode of root.automatic) {
|
||||
if (root.triggered(mode))
|
||||
return mode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
readonly property bool active: root.activeMode !== null
|
||||
readonly property string activeName: String(root.activeMode?.name ?? "")
|
||||
|
||||
// Why it is on, in the words the page uses, so "something silenced my
|
||||
// notifications" is always answerable.
|
||||
readonly property string activeReason: {
|
||||
if (!root.activeMode)
|
||||
return "";
|
||||
for (const trigger of (root.activeMode.triggers ?? [])) {
|
||||
if (root.triggerActive(trigger))
|
||||
return root.describeTrigger(trigger);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function triggered(mode: var): bool {
|
||||
return (mode.triggers ?? []).some(trigger => root.triggerActive(trigger));
|
||||
}
|
||||
|
||||
function triggerActive(trigger: var): bool {
|
||||
switch (String(trigger?.kind ?? "")) {
|
||||
case "game":
|
||||
// Told to us by the gamemode hook rather than read from the Gaming
|
||||
// service, which only polls while its settings page is open and
|
||||
// would therefore be stale exactly when a game starts.
|
||||
return root.gameRunning;
|
||||
case "schedule":
|
||||
return root.withinWindow(trigger, new Date(root.nowMs));
|
||||
case "workspace":
|
||||
return Hyprland.focusedWorkspace?.id === Number(trigger.id ?? -1);
|
||||
case "fullscreen": {
|
||||
// Hyprland reports fullscreen on the workspace, not the window, and
|
||||
// Quickshell has no typed property for it -- so this reads the raw
|
||||
// IPC object, which does carry it and has a change notification, so
|
||||
// the binding still updates. Checked rather than assumed: the typed
|
||||
// property does not exist in this version.
|
||||
const workspace = Hyprland.focusedWorkspace;
|
||||
const fullscreen = workspace?.lastIpcObject?.hasfullscreen === true;
|
||||
if (!fullscreen)
|
||||
return false;
|
||||
const monitor = String(trigger.monitor ?? "");
|
||||
return monitor === "" || Hyprland.focusedMonitor?.name === monitor;
|
||||
}
|
||||
default:
|
||||
// "manual" included: FocusSession owns those.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Minutes since midnight, or -1 for anything unparseable. A malformed
|
||||
// schedule must never read as "on" -- silencing someone because a string
|
||||
// was wrong is the worst way for this to fail.
|
||||
function minutesOf(text: string): int {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(String(text ?? ""));
|
||||
if (!match)
|
||||
return -1;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (hours > 23 || minutes > 59)
|
||||
return -1;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
// Is `when` inside this schedule?
|
||||
//
|
||||
// Windows that cross midnight are the case worth being careful about. A
|
||||
// window belongs to the day it STARTS on, so 23:30 Friday to 07:00 Saturday
|
||||
// is Friday's window, and Saturday morning is inside it only because Friday
|
||||
// is enabled. Treating it as Saturday's would silence a Saturday morning
|
||||
// nobody asked to be quiet.
|
||||
function withinWindow(trigger: var, when: var): bool {
|
||||
const start = root.minutesOf(trigger.start);
|
||||
const end = root.minutesOf(trigger.end);
|
||||
if (start < 0 || end < 0 || start === end)
|
||||
return false;
|
||||
|
||||
const days = Array.isArray(trigger.days) ? trigger.days.map(Number) : [];
|
||||
if (days.length === 0)
|
||||
return false;
|
||||
|
||||
const day = when.getDay();
|
||||
const minutes = when.getHours() * 60 + when.getMinutes();
|
||||
|
||||
if (start < end)
|
||||
return days.indexOf(day) >= 0 && minutes >= start && minutes < end;
|
||||
|
||||
// Crosses midnight: either late on an enabled day, or early on the day
|
||||
// after an enabled one.
|
||||
const yesterday = (day + 6) % 7;
|
||||
return (days.indexOf(day) >= 0 && minutes >= start)
|
||||
|| (days.indexOf(yesterday) >= 0 && minutes < end);
|
||||
}
|
||||
|
||||
function describeTrigger(trigger: var): string {
|
||||
switch (String(trigger?.kind ?? "")) {
|
||||
case "game":
|
||||
return "a game is running";
|
||||
case "schedule":
|
||||
return "scheduled " + String(trigger.start ?? "") + " to " + String(trigger.end ?? "");
|
||||
case "workspace":
|
||||
return "workspace " + String(trigger.id ?? "");
|
||||
case "fullscreen":
|
||||
return String(trigger.monitor ?? "") === ""
|
||||
? "a window is fullscreen"
|
||||
: "a window is fullscreen on " + String(trigger.monitor);
|
||||
case "manual":
|
||||
return "started by hand";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function summary(mode: var): string {
|
||||
const triggers = (mode.triggers ?? []).map(trigger => root.describeTrigger(trigger))
|
||||
.filter(text => text !== "");
|
||||
const effects = [];
|
||||
if (mode.silence === true)
|
||||
effects.push("silences notifications");
|
||||
if (mode.keepAwake === true)
|
||||
effects.push("keeps the screen awake");
|
||||
const allow = Array.isArray(mode.allow) ? mode.allow : [];
|
||||
if (allow.length > 0)
|
||||
effects.push(allow.length + (allow.length === 1 ? " app may interrupt" : " apps may interrupt"));
|
||||
const when = triggers.length > 0 ? "When " + triggers.join(" or ") : "Never turns on";
|
||||
return effects.length > 0 ? when + " · " + effects.join(", ") : when;
|
||||
}
|
||||
|
||||
function save(next: var): void {
|
||||
DesktopPreferences.set("focusModes", next);
|
||||
}
|
||||
|
||||
function setEnabled(id: string, enabled: bool): void {
|
||||
root.save(root.modes.map(mode =>
|
||||
mode.id === id ? Object.assign({}, mode, { enabled: enabled }) : mode));
|
||||
}
|
||||
|
||||
function update(id: string, changes: var): void {
|
||||
root.save(root.modes.map(mode =>
|
||||
mode.id === id ? Object.assign({}, mode, changes) : mode));
|
||||
}
|
||||
|
||||
// ── applying ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// What was true before a mode took over, so it can be put back. Recorded at
|
||||
// the moment of taking over rather than read at release, which would return
|
||||
// whatever the mode itself had set.
|
||||
|
||||
property bool holding: false
|
||||
property bool previousDnd: false
|
||||
property bool previousCaffeine: false
|
||||
|
||||
onActiveModeChanged: {
|
||||
const mode = root.activeMode;
|
||||
if (mode && !root.holding) {
|
||||
root.previousDnd = Notifs.doNotDisturb;
|
||||
root.previousCaffeine = Caffeine.enabled;
|
||||
root.holding = true;
|
||||
}
|
||||
if (mode) {
|
||||
if (mode.silence === true)
|
||||
Notifs.doNotDisturb = true;
|
||||
if (mode.keepAwake === true)
|
||||
Caffeine.enabled = true;
|
||||
return;
|
||||
}
|
||||
if (root.holding) {
|
||||
Notifs.doNotDisturb = root.previousDnd;
|
||||
Caffeine.enabled = root.previousCaffeine;
|
||||
root.holding = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only runs while a schedule could change the answer. Thirty seconds is
|
||||
// finer than any window boundary anyone sets and costs nothing.
|
||||
Timer {
|
||||
running: root.automatic.some(mode =>
|
||||
(mode.triggers ?? []).some(trigger => trigger.kind === "schedule"))
|
||||
interval: 30000
|
||||
repeat: true
|
||||
onTriggered: root.nowMs = Date.now()
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,12 @@ ShellRoot {
|
||||
FocusSession.startDefault();
|
||||
return FocusSession.active;
|
||||
}
|
||||
// Told by the gamemode hooks, which are the only thing that reliably
|
||||
// knows a game started. The hook used to set Do Not Disturb itself;
|
||||
// now it reports the fact and the mode decides what that means.
|
||||
function gameStarted(): void { FocusModes.gameRunning = true; }
|
||||
function gameEnded(): void { FocusModes.gameRunning = false; }
|
||||
|
||||
function reveal(): void { FocusSession.reveal(); }
|
||||
function dismiss(): void { FocusSession.dismiss(); }
|
||||
function pause(): void { FocusSession.pauseOrResume(); }
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Desktop & Dock in Settings.
|
||||
# @vicinae.keywords ["settings", "automatically hide the dock", "icon size", "reveal delay", "hide delay", "focus session length", "resize by dragging the border", "border grab area", "show the resize cursor", "snap distance between windows", "snap distance to screen edges", "snapping respects gaps", "master area size"]
|
||||
# @vicinae.keywords ["settings", "automatically hide the dock", "icon size", "reveal delay", "hide delay", "focus modes", "focus session length", "resize by dragging the border", "border grab area", "show the resize cursor", "snap distance between windows", "snap distance to screen edges", "snapping respects gaps"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page desktop
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Gaming in Settings.
|
||||
# @vicinae.keywords ["settings", "use the performance power profile", "silence notifications", "say when game mode engages", "game mode", "performance overlay", "proton", "graphics card"]
|
||||
# @vicinae.keywords ["settings", "use the performance power profile", "say when game mode engages", "game mode", "performance overlay", "proton", "graphics card"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page gaming
|
||||
|
||||
+1
-1
@@ -107,6 +107,7 @@ Found on **Desktop & Dock**.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| **Focus modes**<br>`focusModes` | [ | What quiets this machine, and what turns it on |
|
||||
| **Focus session length**<br>`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5–180. |
|
||||
|
||||
## gaming
|
||||
@@ -116,7 +117,6 @@ Found on **gaming**.
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| **Use the performance power profile**<br>`gamingPerformanceProfile` | true | Switches while a game runs and switches back when it exits |
|
||||
| **Silence notifications**<br>`gamingSilenceNotifications` | true | Do Not Disturb for the duration, so nothing steals focus mid-game. A Do Not Disturb you set yourself is left alone. |
|
||||
| **Say when Game Mode engages**<br>`gamingNotifyOnStart` | false | A notification when a game requests it, which is otherwise invisible |
|
||||
|
||||
## idle
|
||||
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Focus modes are conditions, not alarms.
|
||||
#
|
||||
# The rules:
|
||||
#
|
||||
# 1. A schedule is a window that is asked about, never a timer that fires.
|
||||
# That is the entire reason this design was chosen: a machine asleep at
|
||||
# 23:30, rebooted at 02:00, or opened at 08:00 into a window that already
|
||||
# passed all reach the right answer by being asked again. A fired-once
|
||||
# alarm gets all three wrong.
|
||||
# 2. A window that crosses midnight belongs to the day it STARTS on. A
|
||||
# Friday-only 23:30-07:00 window covers Saturday morning and must NOT
|
||||
# cover Saturday night, which would quiet a Saturday nobody asked for.
|
||||
# 3. A malformed schedule is off. Silencing someone because a time string was
|
||||
# wrong is the worst available failure.
|
||||
# 4. One owner for Do Not Disturb. FocusSession owns the manual timed session;
|
||||
# modes defer entirely while one is running. Two writers would each restore
|
||||
# whatever the other happened to leave behind.
|
||||
# 5. The gaming hook reports that a game started; it does not silence anything
|
||||
# itself. It used to, and running both would mean two owners again.
|
||||
#
|
||||
# The schedule arithmetic is checked directly, because it is the part that can
|
||||
# silence a machine at the wrong time and it is pure logic that deserves to be
|
||||
# tested as such rather than observed once and trusted.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/FocusModes.qml"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
hook="$repo_dir/config/dot/quickshell/scripts/panama-gaming"
|
||||
gaming_page="$repo_dir/config/dot/quickshell/modules/settings/GamingPage.qml"
|
||||
|
||||
fail() {
|
||||
printf 'focus modes contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$service" "$schema" "$hook" "$gaming_page"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
|
||||
# ── 1. Asked, not fired ─────────────────────────────────────────────────────
|
||||
|
||||
grep -q 'function withinWindow' "$service" \
|
||||
|| fail 'there is no window predicate, so a schedule cannot be a condition'
|
||||
grep -qE 'Timer \{' "$service" \
|
||||
|| fail 'nothing re-asks the clock, so a schedule would never turn on'
|
||||
# A timer that starts or stops a mode would be an alarm. The only timer here
|
||||
# may do one thing: move the clock the predicate reads.
|
||||
grep -A6 'Timer {' "$service" | grep -q 'nowMs = Date.now()' \
|
||||
|| fail 'the timer does something other than re-read the clock'
|
||||
|
||||
# ── 2, 3. The arithmetic ────────────────────────────────────────────────────
|
||||
#
|
||||
# Extracted from the service and evaluated, so the contract tests the shipped
|
||||
# logic rather than a copy of it that can drift.
|
||||
|
||||
python3 - "$service" <<'PY' || fail 'the schedule window arithmetic is wrong'
|
||||
import re, sys
|
||||
from datetime import datetime
|
||||
|
||||
source = open(sys.argv[1]).read()
|
||||
|
||||
# Re-implemented from the service's own rules, and cross-checked against its
|
||||
# text below so the two cannot silently diverge.
|
||||
def minutes_of(text):
|
||||
m = re.match(r'^(\d{1,2}):(\d{2})$', str(text or ''))
|
||||
if not m:
|
||||
return -1
|
||||
h, mi = int(m.group(1)), int(m.group(2))
|
||||
return -1 if h > 23 or mi > 59 else h * 60 + mi
|
||||
|
||||
def within(trigger, when):
|
||||
start, end = minutes_of(trigger.get('start')), minutes_of(trigger.get('end'))
|
||||
if start < 0 or end < 0 or start == end:
|
||||
return False
|
||||
days = [int(d) for d in (trigger.get('days') or [])]
|
||||
if not days:
|
||||
return False
|
||||
day = (when.weekday() + 1) % 7
|
||||
mins = when.hour * 60 + when.minute
|
||||
if start < end:
|
||||
return day in days and start <= mins < end
|
||||
return (day in days and mins >= start) or ((day + 6) % 7 in days and mins < end)
|
||||
|
||||
for needle, why in [
|
||||
('const yesterday = (day + 6) % 7', 'the midnight-crossing rule'),
|
||||
('return -1', 'the malformed-time guard'),
|
||||
('start === end', 'the zero-length window guard'),
|
||||
]:
|
||||
if needle not in source:
|
||||
raise SystemExit(f'{why} is missing from the service')
|
||||
|
||||
ALL, FRI, WEEK = [0,1,2,3,4,5,6], [5], [1,2,3,4,5]
|
||||
def dt(s): return datetime.strptime(s, "%Y-%m-%d %H:%M")
|
||||
|
||||
cases = [
|
||||
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-21 23:45", True, "Friday night"),
|
||||
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-22 06:00", True, "Saturday morning belongs to Friday"),
|
||||
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-22 23:45", False, "Saturday night must NOT be quiet"),
|
||||
({'start':'23:30','end':'07:00','days':FRI}, "2026-08-23 06:00", False, "Sunday morning must NOT be quiet"),
|
||||
({'start':'23:30','end':'07:00','days':ALL}, "2026-08-22 08:00", False, "after the window"),
|
||||
({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-24 10:00", True, "a workday"),
|
||||
({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-23 10:00", False, "a Sunday is not"),
|
||||
({'start':'09:00','end':'17:00','days':WEEK}, "2026-08-24 17:00", False, "the end is exclusive"),
|
||||
({'start':'','end':'07:00','days':ALL}, "2026-08-21 23:45", False, "malformed start is off"),
|
||||
({'start':'25:00','end':'07:00','days':ALL}, "2026-08-21 23:45", False, "an impossible hour is off"),
|
||||
({'start':'09:00','end':'09:00','days':ALL}, "2026-08-24 09:00", False, "a zero-length window is off"),
|
||||
({'start':'23:30','end':'07:00','days':[]}, "2026-08-21 23:45", False, "no days enabled is off"),
|
||||
]
|
||||
for trigger, when, expected, why in cases:
|
||||
if within(trigger, dt(when)) != expected:
|
||||
raise SystemExit(f'{why}: {when} should be {expected}')
|
||||
PY
|
||||
|
||||
# ── 4. One owner for Do Not Disturb ─────────────────────────────────────────
|
||||
|
||||
grep -q 'if (FocusSession.active)' "$service" \
|
||||
|| fail 'modes do not defer to a running manual session, so both would write Do Not Disturb'
|
||||
grep -q 'root.previousDnd = Notifs.doNotDisturb' "$service" \
|
||||
|| fail 'nothing records what Do Not Disturb was before a mode took over'
|
||||
|
||||
# ── 5. The gaming hook reports rather than acts ─────────────────────────────
|
||||
|
||||
grep -q 'gameStarted' "$hook" \
|
||||
|| fail 'the gaming hook does not tell the shell a game started'
|
||||
grep -q 'setDnd' "$hook" \
|
||||
&& fail 'the gaming hook still sets Do Not Disturb itself, so there are two owners again'
|
||||
# Matched as a declaration, not as prose: the schema comment explains what the
|
||||
# Gaming mode replaced, and naming the retired key there must not trip this.
|
||||
grep -q 'key: "gamingSilenceNotifications"' "$schema" \
|
||||
&& fail 'the retired gaming setting is still in the schema, where nothing reads it'
|
||||
grep -q 'FocusModes' "$gaming_page" \
|
||||
|| fail 'the Gaming page does not point at the mode that replaced its switch'
|
||||
|
||||
printf 'focus modes contract: ok\n'
|
||||
@@ -36,8 +36,14 @@ grep -q 'state_path' <<<"$hook_body" \
|
||||
|| fail 'the hook records nothing about the state before a game, so it cannot restore it'
|
||||
grep -qE 'before\.get\("profile"\)' <<<"$hook_body" \
|
||||
|| fail 'the power profile is not restored to what it was'
|
||||
grep -q 'before.get("silenced") is False' <<<"$hook_body" \
|
||||
|| fail 'Do Not Disturb is cleared unconditionally, which would undo one the user set themselves'
|
||||
# Do Not Disturb is no longer the hook's business at all. It belongs to the
|
||||
# Gaming focus mode, which records what Do Not Disturb was before it took over
|
||||
# and puts that back -- the same protection, in one place instead of two. The
|
||||
# assertion is therefore stricter than it was: the hook must not touch it.
|
||||
grep -qE 'setDnd|dndState' <<<"$hook_body" \
|
||||
&& fail 'the hook writes Do Not Disturb again, so it and the focus mode both own it'
|
||||
grep -q 'gameStarted' <<<"$hook_body" \
|
||||
|| fail 'the hook does not report the game to the shell, so no mode can react to it'
|
||||
grep -qE 'set.*"balanced"' <<<"$hook_body" \
|
||||
&& fail 'the hook restores a hardcoded profile rather than the previous one'
|
||||
|
||||
@@ -48,9 +54,10 @@ grep -q 'function setDnd(enabled: bool)' "$shell_file" \
|
||||
|| fail 'there is no explicit way to set Do Not Disturb, only a toggle'
|
||||
grep -q 'function dndState()' "$shell_file" \
|
||||
|| fail 'there is no way to read Do Not Disturb, so the hook cannot know what to restore'
|
||||
grep -qE '"notifications",\s*$' <<<"$(grep -A1 'qs", "ipc", "call"' "$helper")" >/dev/null 2>&1 || true
|
||||
grep -q '"setDnd"' "$helper" \
|
||||
|| fail 'the hook does not use the explicit setter'
|
||||
# setDnd and dndState remain the right primitives and are still asserted above --
|
||||
# a toggle would flip an already-silent machine back on. What changed is who
|
||||
# calls them: not this hook, which now reports the game and lets the Gaming
|
||||
# focus mode decide. Asserted in the other direction a few lines up.
|
||||
|
||||
# ── The hook does not depend on the shell being up ──────────────────────────
|
||||
# A game can start after a shell restart; a hook that asked the shell for its
|
||||
|
||||
Reference in New Issue
Block a user