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 ? " 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)); } // ── 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() } }