Files
Panama/config/dot/quickshell/services/FocusModes.qml
T
Gabriel Brown 1b845c0126 Rebuild Home around what this desktop already knows
Home led with a diagram of the attached monitor -- DP-2, 4500 x 3000, 1.13x
scale, XRGB2101010 -- which is Displays-page data, was the largest thing on the
screen, and has never been needed there. Under it sat a permanently open search
field for a weather location that is set about once a year, and then roughly
half a page of nothing.

It now opens with the two or three things somebody would open Home to do:
starting or ending a focus session, Do Not Disturb, and what is running. The Do
Not Disturb switch is disabled and says why while a mode is holding it, so it
cannot appear to be a control that is being ignored.

Findings sit above a reassurance line, the same shape the Firewall and
Containers pages use. Each finding names the page that can resolve it, because a
home page reporting a problem it cannot help with is only an alarm. On this
machine that is one finding today -- PostgreSQL and Redis reachable from the
network -- above "27 health checks pass, 522 GB free, snapshots ran at 02:00".
"Do next" appears only when there is something in it.

Weather stays, as the greeting's second line rather than a card with a search
box open, and its picker is collapsed behind the current location.

FocusModes was missing `import qs.config`, so DesktopPreferences was undefined
and the modes list came back undefined with it -- shipped two commits ago with
nothing noticing, because until Home referenced the service no isolated shell
had ever instantiated it. Focus modes would have quietly had no modes. Pulling
these services together on one page is what surfaced it.

LocationPicker gained a picked signal, the same way DisplayModePicker did, so a
container can put the search away without reaching into it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 09:15:01 -04:00

257 lines
10 KiB
QML

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
import qs.config
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
// Applications the mode in force lets through anyway, by the same id the
// per-application rules use. Empty whenever no mode is active, which is
// what keeps a Do Not Disturb somebody set by hand absolute: an exception
// belongs to a mode, so without a mode there are no exceptions.
readonly property var allowedApps: {
const mode = root.activeMode;
if (!mode || mode.silence !== true)
return [];
return Array.isArray(mode.allow) ? mode.allow.map(String) : [];
}
function allows(appId: string): bool {
return root.allowedApps.indexOf(String(appId)) >= 0;
}
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()
}
}