Files
Panama/config/dot/quickshell/services/FocusModes.qml
T

391 lines
16 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.
//
// 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"))
// 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 ? " application may interrupt" : " applications 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));
}
// ── 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
// 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()
}
}