// Writing a window rule as behaviors instead of as a regex. // // Two halves, in the order somebody actually thinks in: which application, then // what it should do. The application half offers the windows open right now // first -- that is the list where the class is a fact rather than a guess, // because Hyprland is reporting it -- and falls back to a search over installed // applications, whose class comes from their own StartupWMClass. // // The behavior half is ticks. Nothing here composes a rule string: the draft is // booleans and two bounded numbers, WindowRules validates it, and hypr/rules.lua // escapes the class before the compositor's matcher sees it. // // Panama's own surfaces are not offerable and not typeable: a rule that floated // a Quickshell layer would break the desktop from inside Settings. import QtQuick import Quickshell import Quickshell.Hyprland import qs.config import qs.services import qs.modules.clipboard Column { id: root // Set to an existing rule to edit it; null to add a new one. property var editing: null signal committed(var rule) signal canceled property string windowClass: "" property string appLabel: "" property bool floats: false property bool center: false property bool noAnim: false property bool game: false property bool noDim: false property bool pin: false property bool sizeOn: false property int sizeWidth: 900 property int sizeHeight: 600 property bool workspaceOn: false property int workspace: 1 readonly property bool chosen: root.windowClass !== "" function load(rule: var): void { root.editing = rule ?? null; root.windowClass = String(rule?.class ?? ""); root.appLabel = String(rule?.label ?? rule?.class ?? ""); root.floats = rule?.float === true; root.center = rule?.center === true; root.noAnim = rule?.noAnim === true; root.game = rule?.game === true; root.noDim = rule?.noDim === true; root.pin = rule?.pin === true; root.sizeOn = Array.isArray(rule?.size) && rule.size.length === 2; root.sizeWidth = root.sizeOn ? rule.size[0] : 900; root.sizeHeight = root.sizeOn ? rule.size[1] : 600; root.workspaceOn = Number.isFinite(rule?.workspace); root.workspace = root.workspaceOn ? rule.workspace : 1; appSearch.text = ""; } function reset(): void { root.load(null); } function draft(): var { return { "class": root.windowClass, label: root.appLabel === "" ? root.windowClass : root.appLabel, float: root.floats, center: root.center, size: root.sizeOn ? [root.sizeWidth, root.sizeHeight] : null, workspace: root.workspaceOn ? root.workspace : null, noAnim: root.noAnim, game: root.game, noDim: root.noDim, pin: root.pin }; } readonly property bool complete: root.chosen && WindowRules.validRule(root.draft()) && WindowRules.hasBehavior(root.draft()) width: parent ? parent.width : 620 spacing: 0 SectionLabel { text: root.editing ? "Edit rule" : "New rule" count: root.chosen ? root.windowClass : "pick an application" } // ── Which application ─────────────────────────────────────────────────── // Hidden while editing: the class is the rule's identity, and letting it be // changed in place would be a second rule wearing the first one's history. Column { width: parent.width visible: root.editing === null spacing: 0 ChoiceGrid { width: parent.width visible: root.openWindows.length > 0 label: "Open right now" detail: "The class comes from the compositor, so these are exact." current: root.windowClass options: root.openWindows divider: false onPicked: value => { root.windowClass = String(value); const found = root.openWindows.find(option => option.value === root.windowClass); root.appLabel = found ? String(found.label) : root.windowClass; } } SearchField { id: appSearch width: parent.width placeholder: "…or search installed applications" } Repeater { model: root.appMatches SettingRow { id: candidate required property var modelData required property int index label: String(candidate.modelData.label ?? "") detail: String(candidate.modelData.value ?? "") value: candidate.modelData.value === root.windowClass ? "Chosen" : "" controlWidth: 96 divider: candidate.index < root.appMatches.length - 1 activatable: true onActivated: { root.windowClass = String(candidate.modelData.value ?? ""); root.appLabel = String(candidate.modelData.label ?? ""); } } } Text { width: parent.width visible: root.appMatches.length === 0 && appSearch.text.trim() !== "" text: "Nothing installed matches that." color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall topPadding: 10 bottomPadding: 10 } } // ── What it should do ─────────────────────────────────────────────────── Column { width: parent.width visible: root.chosen spacing: 0 SectionLabel { text: "Behavior" count: root.appLabel } Flow { width: parent.width spacing: 7 bottomPadding: 12 SettingsChip { text: "Float" active: root.floats onClicked: root.floats = !root.floats } SettingsChip { text: "Center when opened" active: root.center onClicked: root.center = !root.center } SettingsChip { text: "Fixed size" active: root.sizeOn onClicked: root.sizeOn = !root.sizeOn } SettingsChip { text: "Open on a workspace" active: root.workspaceOn onClicked: root.workspaceOn = !root.workspaceOn } SettingsChip { text: "No animations" active: root.noAnim onClicked: root.noAnim = !root.noAnim } SettingsChip { text: "Treat as a game" active: root.game onClicked: root.game = !root.game } SettingsChip { text: "Never dim" active: root.noDim onClicked: root.noDim = !root.noDim } SettingsChip { text: "Pin on every workspace" active: root.pin onClicked: root.pin = !root.pin } } TextFieldRow { visible: root.sizeOn label: "Width" detail: "Pixels, between " + WindowRules.minSize + " and " + WindowRules.maxSize text: String(root.sizeWidth) onAccepted: value => { const parsed = parseInt(value, 10); if (Number.isFinite(parsed)) root.sizeWidth = parsed; } } TextFieldRow { visible: root.sizeOn label: "Height" text: String(root.sizeHeight) onAccepted: value => { const parsed = parseInt(value, 10); if (Number.isFinite(parsed)) root.sizeHeight = parsed; } } ChoiceGrid { width: parent.width visible: root.workspaceOn label: "Workspace" detail: "The ten the keymap reaches." current: root.workspace options: root.workspaceOptions divider: false onPicked: value => root.workspace = Number(value) } Item { width: parent.width height: 50 Row { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { tone: "accent" text: root.editing ? "Save the rule" : "Add the rule" enabled: root.complete && !WindowRules.reloading onClicked: root.committed(root.draft()) } SettingsButton { text: "Cancel" onClicked: root.canceled() } } Text { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: parent.width - 240 horizontalAlignment: Text.AlignRight text: root.complete ? WindowRules.ruleLineFor(root.draft()) : "Tick at least one behavior — a rule that does nothing is a row you will wonder about later." color: Theme.fgMuted font.family: root.complete ? Theme.fontMono : Theme.fontFamily font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } } } // ── The candidate lists ───────────────────────────────────────────────── readonly property var workspaceOptions: { const out = []; for (let n = 1; n <= WindowRules.maxWorkspace; n++) out.push({ value: n, label: String(n) }); return out; } // Windows open now, one entry per class. `wayland.appId` is the class // Hyprland matches rules against; there is no separate class property. readonly property var openWindows: { const seen = {}; const out = []; for (const toplevel of (Hyprland.toplevels?.values ?? [])) { const appId = String(toplevel?.wayland?.appId ?? ""); if (appId === "" || seen[appId]) continue; if (!WindowRules.validClass(appId)) continue; if (WindowRules.indexOfClass(appId) >= 0) continue; seen[appId] = true; const entry = DesktopEntries.heuristicLookup(appId); out.push({ value: appId, label: String(entry?.name ?? appId) }); } return out; } readonly property var appMatches: { const needle = appSearch.text.trim().toLowerCase(); if (needle === "") return []; const out = []; for (const entry of DesktopEntries.applications.values) { if (entry.noDisplay) continue; if (String(entry.name).toLowerCase().indexOf(needle) < 0) continue; const windowClass = String(entry.startupClass ?? "") !== "" ? String(entry.startupClass) : String(entry.id ?? "").replace(/\.desktop$/, ""); if (!WindowRules.validClass(windowClass)) continue; if (WindowRules.indexOfClass(windowClass) >= 0) continue; out.push({ value: windowClass, label: String(entry.name) }); if (out.length >= 8) break; } return out; } }