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:
Gabriel Brown
2026-08-25 01:51:59 -04:00
parent f9e5d3f470
commit 06c53d6c21
48 changed files with 4749 additions and 140 deletions
@@ -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
}