Files

292 lines
12 KiB
QML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}