Files

349 lines
12 KiB
QML

// 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;
}
}