292 lines
12 KiB
QML
292 lines
12 KiB
QML
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
|
||
}
|