Shortcuts you invent, rules you write, gestures you own - all still just data
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user