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,348 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user