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() } }