391 lines
16 KiB
QML
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()
|
|
}
|
|
}
|