pragma Singleton // ───────────────────────────────────────────────────────────────────────────── // Per-application window rules: how a named application behaves when it opens. // // Modelled on Workspaces.qml, and for the same reason. A window rule is read by // Hyprland at config time and cannot be taken back at runtime, so the only way // to change the set is `hyprctl reload`, which re-runs the config and lets // hypr/rules.lua emit exactly the rules the preference asks for. // // That makes this service two things: the reload, and an honest answer to "has // it taken effect yet". The second is the harder half here, because Hyprland // publishes no window-rule listing -- `hyprctl` offers `workspacerules` and // nothing equivalent for these. So `applied` is not a read-back of the rules // themselves: it is the exit status of the reload that last ran, against the // rule set that was stored when it ran. The page says applied-on-reload in // those words rather than dressing that up as a confirmation it is not. // // What CAN be read back is the effect: `matchesOpen()` counts the windows open // right now whose class a rule names, which is the difference between "we wrote // a rule for org.gnome.Calculator" and "the calculator on your screen is the // thing this rule is about". // // Nothing stored here is a command. A rule is a literal class string plus // booleans and two bounded numbers; hypr/rules.lua regex-escapes the class // before Hyprland's matcher sees it and skips any entry that fails validation, // so an entry that arrived by hand-editing settings.json is inert rather than // dangerous. // ───────────────────────────────────────────────────────────────────────────── import Quickshell import Quickshell.Io import Quickshell.Hyprland import QtQuick import qs.config Singleton { id: root // [{ class, label, float, center, size, workspace, noAnim, game, noDim, pin }] readonly property var rules: { const stored = DesktopPreferences.get("windowRules"); return Array.isArray(stored) ? stored : []; } property bool reloading: false property string lastError: "" // The rule set the last successful reload actually carried. Compared by // value because the array is rewritten wholesale on every edit. property string appliedSignature: "" readonly property string signature: JSON.stringify(root.rules) // Has the compositor been through a reload since the rules last changed? // An empty rule set needs no reload to be true of the compositor, so it is // applied by definition. readonly property bool applied: root.rules.length === 0 || root.appliedSignature === root.signature // ── Validation ────────────────────────────────────────────────────────── // The same rules hypr/rules.lua applies, so the page can refuse an entry // rather than saving one the compositor will silently drop. readonly property int maxClassLength: 128 readonly property int minSize: 50 readonly property int maxSize: 10000 readonly property int maxWorkspace: 10 // The shell's own surfaces are not addressable. A rule that floated or // moved a Quickshell layer would break the desktop from inside Settings, // and there is no legitimate reason to write one. readonly property var shellClassPattern: /^(quickshell|qs-)/i function isShellClass(windowClass: string): bool { return root.shellClassPattern.test(String(windowClass).trim()); } // A class is matched literally, so anything printable is allowed -- but a // control character or a newline could not have come from a real window and // would end up inside a compositor rule. function validClass(windowClass: string): bool { const text = String(windowClass ?? "").trim(); if (text === "" || text.length > root.maxClassLength) return false; if (/[\x00-\x1f\x7f]/.test(text)) return false; return !root.isShellClass(text); } function validSize(size: var): bool { if (size === null || size === undefined) return true; if (!Array.isArray(size) || size.length !== 2) return false; return size.every(value => Number.isFinite(value) && value >= root.minSize && value <= root.maxSize); } function validWorkspace(workspace: var): bool { if (workspace === null || workspace === undefined) return true; return Number.isFinite(workspace) && workspace >= 1 && workspace <= root.maxWorkspace; } function validRule(rule: var): bool { if (!rule || typeof rule !== "object") return false; return root.validClass(rule.class) && root.validSize(rule.size ?? null) && root.validWorkspace(rule.workspace ?? null); } // A rule that ticks nothing is a rule that does nothing, which is a row // somebody would later wonder about. function hasBehavior(rule: var): bool { if (!rule) return false; return rule.float === true || rule.center === true || rule.noAnim === true || rule.game === true || rule.noDim === true || rule.pin === true || (Array.isArray(rule.size) && rule.size.length === 2) || Number.isFinite(rule.workspace); } // ── How a rule reads ──────────────────────────────────────────────────── // Two spellings, both from here so the page never invents a third: the // plain sentence somebody chose these ticks by, and the compositor line // underneath it for anyone who wants to see what was actually written. function summaryFor(rule: var): string { const parts = []; if (rule?.float === true) parts.push("Floats"); if (Array.isArray(rule?.size) && rule.size.length === 2) parts.push(`fixed size (${rule.size[0]} × ${rule.size[1]})`); if (rule?.center === true) parts.push("centered"); if (Number.isFinite(rule?.workspace)) parts.push("opens on workspace " + rule.workspace); if (rule?.game === true) parts.push("treated as a game"); if (rule?.noAnim === true) parts.push("no animations"); if (rule?.noDim === true) parts.push("never dimmed"); if (rule?.pin === true) parts.push("pinned to every workspace"); if (parts.length === 0) return "No behavior chosen — this rule does nothing"; return parts.join(" · "); } function ruleLineFor(rule: var): string { const verbs = []; if (rule?.float === true) verbs.push("float"); if (Array.isArray(rule?.size) && rule.size.length === 2) verbs.push(`size ${rule.size[0]} ${rule.size[1]}`); if (rule?.center === true) verbs.push("center"); if (Number.isFinite(rule?.workspace)) verbs.push("workspace " + rule.workspace); if (rule?.game === true) verbs.push("content:game"); if (rule?.noAnim === true) verbs.push("no_anim"); if (rule?.noDim === true) verbs.push("no_dim"); if (rule?.pin === true) verbs.push("pin"); return `match class ${String(rule?.class ?? "")} → ${verbs.length === 0 ? "nothing" : verbs.join(", ")}`; } // ── The effect, read from the live desktop ────────────────────────────── function indexOfClass(windowClass: string): int { const wanted = String(windowClass).trim(); return root.rules.findIndex(rule => String(rule?.class ?? "").trim() === wanted); } // Windows open right now that this rule's class names. Hyprland reports a // Wayland client's app id, which is the class its rules match on. function matchesOpen(windowClass: string): int { const wanted = String(windowClass).trim(); if (wanted === "") return 0; let count = 0; for (const toplevel of (Hyprland.toplevels?.values ?? [])) { if (toplevel?.wayland?.appId === wanted) count++; } return count; } // ── Editing ───────────────────────────────────────────────────────────── // Each write replaces the whole array and then reloads, because that is the // only way a removed rule stops applying. function write(next: var, failure: string): bool { if (!DesktopPreferences.set("windowRules", next)) { root.lastError = failure; return false; } root.lastError = ""; root.apply(); return true; } function addRule(rule: var): bool { if (!root.validRule(rule)) { root.lastError = root.isShellClass(rule?.class ?? "") ? "Panama's own surfaces cannot be given window rules." : "That rule is not one the compositor would accept."; return false; } if (root.indexOfClass(rule.class) >= 0) { root.lastError = `There is already a rule for ${rule.class}.`; return false; } const next = root.rules.slice(); next.push(rule); return root.write(next, "That rule could not be saved."); } function updateRule(windowClass: string, rule: var): bool { const at = root.indexOfClass(windowClass); if (at < 0) return false; if (!root.validRule(rule)) { root.lastError = "That rule is not one the compositor would accept."; return false; } const next = root.rules.slice(); next[at] = rule; return root.write(next, "That rule could not be saved."); } function removeRule(windowClass: string): bool { const at = root.indexOfClass(windowClass); if (at < 0) return false; const next = root.rules.slice(); next.splice(at, 1); return root.write(next, "That rule could not be removed."); } // ── Applying ──────────────────────────────────────────────────────────── Process { id: reloadRun command: ["hyprctl", "reload"] onExited: (exitCode, exitStatus) => { root.reloading = false; if (exitCode !== 0) { root.lastError = "The compositor did not reload, so the rules above are not in effect yet."; return; } root.lastError = ""; settle.restart(); } } Timer { id: settle interval: 350 // The rules the reload just read are the ones stored at that moment. // Recorded after the settle rather than before the reload so a write // that lands late is not credited to a reload that ran before it. onTriggered: root.appliedSignature = root.signature } Timer { id: reloadDelay interval: 120 onTriggered: reloadRun.running = true } function apply(): void { if (reloadRun.running) return; root.reloading = true; // DesktopPreferences coalesces its write on a timer; the reload has to // land after it or it re-reads the previous file. reloadDelay.restart(); } // A rule set that was already in the config when the shell started is in // effect: the compositor read it at login. Recorded once so an untouched // list does not read as pending forever. Component.onCompleted: root.appliedSignature = root.signature }