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:
@@ -98,6 +98,13 @@ Singleton {
|
||||
// Displayed binds come from the compositor and so already reflect any
|
||||
// override; the override map is what tells us where they started.
|
||||
function shippedChordFor(currentChord: string): string {
|
||||
// Custom chords are outside the override map's domain: a custom bind
|
||||
// has no shipped chord to have been moved from, and answering one from
|
||||
// the map would let a shipped bind's override claim a user's own
|
||||
// shortcut. See the customBinds section below.
|
||||
if (root.isCustomChord(currentChord))
|
||||
return currentChord;
|
||||
|
||||
for (const shipped in root.overrides) {
|
||||
if (root.overrides[shipped] === currentChord)
|
||||
return shipped;
|
||||
@@ -131,6 +138,12 @@ Singleton {
|
||||
}
|
||||
|
||||
function rebind(currentChord: string, newChord: string): bool {
|
||||
// A custom bind is edited in place in `customBinds`; it never enters
|
||||
// the override map. Routing here rather than refusing keeps the two
|
||||
// mechanisms from ever meeting even if a caller does not check first.
|
||||
if (root.isCustomChord(currentChord))
|
||||
return root.rebindCustomBind(currentChord, newChord);
|
||||
|
||||
if (newChord === "" || newChord === currentChord)
|
||||
return false;
|
||||
|
||||
@@ -183,6 +196,209 @@ Singleton {
|
||||
root.applyReload();
|
||||
}
|
||||
|
||||
// ── Named actions ───────────────────────────────────────────────────────
|
||||
// A custom shortcut and an assigned four-finger gesture both store DATA,
|
||||
// never a command: an enum `kind`, a validated `target`, and the `label`
|
||||
// to show. hypr/actions.lua turns that data into something the compositor
|
||||
// runs, through whitelist tables only -- so nothing a person can type into
|
||||
// settings.json becomes executable, and an unknown kind or an invalid
|
||||
// target means the bind is silently not emitted rather than guessed at.
|
||||
//
|
||||
// This is the same vocabulary on the QML side. `describeAction()` is the
|
||||
// single authority here on whether an entry is one the Lua would emit;
|
||||
// every page asks it rather than re-deriving the rules.
|
||||
|
||||
readonly property var actionKinds: ["app", "shell", "window"]
|
||||
|
||||
// An application id is an ARGUMENT to the launch-or-focus path, never text
|
||||
// interpolated into a command, and this is the shape that path accepts.
|
||||
readonly property var safeTargetPattern: /^[A-Za-z0-9@._-]{1,128}$/
|
||||
|
||||
// Shell verbs, each one a surface `shell.qml` already exposes over IPC (or,
|
||||
// for the last two, a command hypr/keybinds.lua already binds). The target
|
||||
// strings are keys of the whitelist table in hypr/actions.lua -- adding one
|
||||
// here without adding it there means the entry simply never emits.
|
||||
readonly property var shellActions: [
|
||||
{ target: "dnd-toggle", label: "Toggle Do Not Disturb" },
|
||||
{ target: "screenshot", label: "Screenshot or record" },
|
||||
{ target: "screenshot-screen", label: "Screenshot the whole screen" },
|
||||
{ target: "screenshot-window", label: "Screenshot the focused window" },
|
||||
{ target: "screen-intelligence", label: "Read text on screen" },
|
||||
{ target: "color-picker", label: "Pick a color" },
|
||||
{ target: "clipboard", label: "Clipboard history" },
|
||||
{ target: "launcher", label: "Open the launcher" },
|
||||
{ target: "overview", label: "Open Mission Control" },
|
||||
{ target: "quick-settings", label: "Open Quick Settings" },
|
||||
{ target: "notifications", label: "Open notifications" },
|
||||
{ target: "activity", label: "Open Activity" },
|
||||
{ target: "cheatsheet", label: "Keyboard shortcuts" },
|
||||
{ target: "settings", label: "Open Settings" },
|
||||
{ target: "focus-session", label: "Focus session" },
|
||||
{ target: "caffeine", label: "Keep the screen awake" },
|
||||
{ target: "night-light", label: "Toggle Night Light" },
|
||||
{ target: "power-menu", label: "Power menu" },
|
||||
{ target: "lock", label: "Lock the screen" }
|
||||
]
|
||||
|
||||
// Compositor verbs. The three window-state ones, then the ten workspaces
|
||||
// the keymap already reaches -- generated rather than typed so the range
|
||||
// and hypr/actions.lua's 1..10 check can never disagree.
|
||||
//
|
||||
// `workspace:N` goes TO that workspace; it does not carry the focused
|
||||
// window there. Said in the label because "workspace 4" on its own reads
|
||||
// like either one.
|
||||
readonly property var windowActions: {
|
||||
const out = [
|
||||
{ target: "float-toggle", label: "Toggle floating" },
|
||||
{ target: "fullscreen", label: "Fullscreen" },
|
||||
{ target: "pin", label: "Pin on every workspace" }
|
||||
];
|
||||
for (let n = 1; n <= 10; n++)
|
||||
out.push({ target: "workspace:" + n, label: "Go to workspace " + n });
|
||||
return out;
|
||||
}
|
||||
|
||||
function shellActionLabel(target: string): string {
|
||||
const found = root.shellActions.find(action => action.target === target);
|
||||
return found ? found.label : "";
|
||||
}
|
||||
|
||||
function windowActionLabel(target: string): string {
|
||||
const found = root.windowActions.find(action => action.target === target);
|
||||
return found ? found.label : "";
|
||||
}
|
||||
|
||||
// What an entry does, in a sentence -- or "" when it is not an action the
|
||||
// Lua would emit, which is what every caller checks rather than validating
|
||||
// kind and target for itself.
|
||||
function describeAction(entry: var): string {
|
||||
if (!entry || typeof entry !== "object")
|
||||
return "";
|
||||
const kind = String(entry.kind ?? "");
|
||||
const target = String(entry.target ?? "");
|
||||
if (target === "")
|
||||
return "";
|
||||
|
||||
if (kind === "app")
|
||||
return root.safeTargetPattern.test(target) ? "Application · launch-or-focus" : "";
|
||||
if (kind === "shell") {
|
||||
const shell = root.shellActionLabel(target);
|
||||
return shell === "" ? "" : "Shell action · " + shell;
|
||||
}
|
||||
if (kind === "window") {
|
||||
const window = root.windowActionLabel(target);
|
||||
return window === "" ? "" : "Window · " + window;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── Custom shortcuts ────────────────────────────────────────────────────
|
||||
// [{ chord, kind, target, label }]. hypr/keybinds.lua emits these after the
|
||||
// shipped binds under a "Custom" category, skipping any entry whose chord
|
||||
// is invalid, whose label is empty, whose action does not resolve, or whose
|
||||
// chord a shipped bind already holds. This end refuses all four upstream so
|
||||
// that a saved shortcut is a working one.
|
||||
readonly property var customBinds: {
|
||||
const stored = DesktopPreferences.get("customBinds");
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
|
||||
function normalizedChord(chord: string): string {
|
||||
return String(chord).replace(/\s+/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function isCustomChord(chord: string): bool {
|
||||
const wanted = root.normalizedChord(chord);
|
||||
if (wanted === "")
|
||||
return false;
|
||||
return root.customBinds.some(entry => root.normalizedChord(entry?.chord ?? "") === wanted);
|
||||
}
|
||||
|
||||
function customBindFor(chord: string): var {
|
||||
const wanted = root.normalizedChord(chord);
|
||||
return root.customBinds.find(entry => root.normalizedChord(entry?.chord ?? "") === wanted) ?? null;
|
||||
}
|
||||
|
||||
// Is the compositor actually answering this chord with this action? A
|
||||
// stored entry is an intention; the keymap is the fact. Reported as true
|
||||
// before the first read so a fresh page does not flash a warning it has no
|
||||
// basis for.
|
||||
function customBindApplied(entry: var): bool {
|
||||
if (!root.loaded || !entry)
|
||||
return true;
|
||||
return root.boundTo(String(entry.chord ?? ""), "") === String(entry.label ?? "");
|
||||
}
|
||||
|
||||
function writeCustomBinds(next: var, failure: string): bool {
|
||||
if (!DesktopPreferences.set("customBinds", next)) {
|
||||
root.lastError = failure;
|
||||
return false;
|
||||
}
|
||||
root.applyReload();
|
||||
return true;
|
||||
}
|
||||
|
||||
function addCustomBind(chord: string, kind: string, target: string, label: string): bool {
|
||||
const trimmed = String(label).trim();
|
||||
if (chord === "" || trimmed === "") {
|
||||
root.lastError = "A shortcut needs a chord and a name.";
|
||||
return false;
|
||||
}
|
||||
if (root.describeAction({ kind: kind, target: target }) === "") {
|
||||
root.lastError = "That is not an action Panama can bind.";
|
||||
return false;
|
||||
}
|
||||
|
||||
const taken = root.boundTo(chord, "");
|
||||
if (taken !== "") {
|
||||
root.lastError = `${chord} is already ${taken}.`;
|
||||
return false;
|
||||
}
|
||||
if (root.isCustomChord(chord)) {
|
||||
root.lastError = `${chord} is already one of your shortcuts.`;
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = root.customBinds.slice();
|
||||
next.push({ chord: chord, kind: String(kind), target: String(target), label: trimmed });
|
||||
return root.writeCustomBinds(next, "That shortcut could not be saved.");
|
||||
}
|
||||
|
||||
function rebindCustomBind(currentChord: string, newChord: string): bool {
|
||||
if (newChord === "" || newChord === currentChord)
|
||||
return false;
|
||||
|
||||
const at = root.customBinds.findIndex(entry =>
|
||||
root.normalizedChord(entry?.chord ?? "") === root.normalizedChord(currentChord));
|
||||
if (at < 0)
|
||||
return false;
|
||||
|
||||
// `exceptCurrent` is the chord being vacated, so a shortcut can be
|
||||
// re-recorded onto the chord it already holds without refusing itself.
|
||||
const taken = root.boundTo(newChord, currentChord);
|
||||
if (taken !== "") {
|
||||
root.lastError = `${newChord} is already ${taken}.`;
|
||||
return false;
|
||||
}
|
||||
if (root.isCustomChord(newChord)) {
|
||||
root.lastError = `${newChord} is already one of your shortcuts.`;
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = root.customBinds.slice();
|
||||
next[at] = Object.assign({}, next[at], { chord: newChord });
|
||||
return root.writeCustomBinds(next, "That shortcut could not be saved.");
|
||||
}
|
||||
|
||||
function removeCustomBind(chord: string): bool {
|
||||
const wanted = root.normalizedChord(chord);
|
||||
const next = root.customBinds.filter(entry =>
|
||||
root.normalizedChord(entry?.chord ?? "") !== wanted);
|
||||
if (next.length === root.customBinds.length)
|
||||
return false;
|
||||
return root.writeCustomBinds(next, "That shortcut could not be removed.");
|
||||
}
|
||||
|
||||
Process {
|
||||
id: reloadRun
|
||||
command: ["hyprctl", "reload"]
|
||||
@@ -388,7 +604,10 @@ Singleton {
|
||||
// after them are the ones the substring derivation produces, kept so a
|
||||
// machine whose compositor has not reloaded since the manifest was added
|
||||
// still sorts into a sensible order rather than alphabetically.
|
||||
readonly property var groupOrder: ["Windows", "Workspaces", "Applications", "Shell",
|
||||
// Custom leads: a list of a hundred and thirty shipped binds is somewhere
|
||||
// to look things up, and the two you invented are the two you came for.
|
||||
readonly property var groupOrder: ["Custom",
|
||||
"Windows", "Workspaces", "Applications", "Shell",
|
||||
"Session", "Media & hardware", "Other",
|
||||
"Focus", "Move & split", "Size", "Window state",
|
||||
"Applications & shell", "Media & hardware keys"]
|
||||
|
||||
@@ -35,6 +35,12 @@ Singleton {
|
||||
// connection name -> the helper's connection shape. See detailsFor().
|
||||
property var details: ({})
|
||||
|
||||
// Every profile NetworkManager holds, in range or not. See the `saved`
|
||||
// verb: this is the list that is otherwise invisible until you are standing
|
||||
// next to the network you wanted to tidy up.
|
||||
property var savedConnections: []
|
||||
property bool savedScanned: false
|
||||
|
||||
property var hotspot: ({})
|
||||
property string proxyMode: "none"
|
||||
property string proxyHost: ""
|
||||
@@ -54,7 +60,7 @@ Singleton {
|
||||
|
||||
// Guards read the Process objects directly; a derived binding is stale
|
||||
// inside the handler that changes it. See DefaultApps.qml.
|
||||
readonly property bool busy: mutation.running || enterprise.running || detailsQuery.running
|
||||
readonly property bool busy: mutation.running || passwordJoin.running || detailsQuery.running
|
||||
|
||||
// Connections whose details have been asked for, in order, so a burst of
|
||||
// requests becomes one query at a time rather than one Process each.
|
||||
@@ -131,6 +137,18 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
function absorbSaved(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.savedConnections = Array.isArray(parsed.connections) ? parsed.connections : [];
|
||||
root.savedScanned = true;
|
||||
if (String(parsed.error ?? "") !== "")
|
||||
root.lastError = String(parsed.error);
|
||||
} catch (error) {
|
||||
root.lastError = "Could not read the saved networks.";
|
||||
}
|
||||
}
|
||||
|
||||
function absorbHotspot(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
@@ -185,6 +203,7 @@ Singleton {
|
||||
function absorb(shape: string, subject: string, text: string): void {
|
||||
switch (shape) {
|
||||
case "connection": root.absorbDetails(subject, text); break;
|
||||
case "saved": root.absorbSaved(text); break;
|
||||
case "hotspot": root.absorbHotspot(text); break;
|
||||
case "proxy": root.absorbProxy(text); break;
|
||||
case "airplane": root.absorbAirplane(text); break;
|
||||
@@ -206,6 +225,10 @@ Singleton {
|
||||
|
||||
function forget(connection: string): void {
|
||||
root.run("connection", connection, ["forget", connection]);
|
||||
// The saved list is the one surface that shows profiles nobody is
|
||||
// standing next to, so a forget nobody re-reads leaves a row for a
|
||||
// profile that no longer exists. The timer waits for the mutation.
|
||||
root.refreshSavedSoon();
|
||||
}
|
||||
|
||||
function setAutoconnect(connection: string, enabled: bool): void {
|
||||
@@ -218,6 +241,36 @@ Singleton {
|
||||
["set-mac-random", connection, enabled ? "true" : "false"]);
|
||||
}
|
||||
|
||||
// "yes", "no" or "auto". Three states rather than two, because "automatic"
|
||||
// is NetworkManager guessing from what the network said and "no" is a claim
|
||||
// — see the helper's set_metered.
|
||||
function setMetered(connection: string, mode: string): void {
|
||||
root.run("connection", connection, ["set-metered", connection, String(mode)]);
|
||||
}
|
||||
|
||||
// ---- static addressing
|
||||
//
|
||||
// family is "4" or "6" — the helper's own spelling, so nothing has to
|
||||
// translate between two vocabularies on the way down.
|
||||
|
||||
function setIpAuto(connection: string, family: string): void {
|
||||
root.run("connection", connection, ["set-ip", connection, String(family), "auto"]);
|
||||
}
|
||||
|
||||
// Every field on every call, including the empty ones: the helper writes
|
||||
// the whole stack at once so that switching modes cannot leave half the old
|
||||
// configuration behind. `dns` is the comma-separated list as typed.
|
||||
function setIpManual(connection: string, family: string, address: string,
|
||||
gateway: string, dns: string): void {
|
||||
root.run("connection", connection,
|
||||
["set-ip", connection, String(family), "manual",
|
||||
String(address), String(gateway), String(dns)]);
|
||||
}
|
||||
|
||||
// ---- saved profiles
|
||||
|
||||
function refreshSaved(): void { root.run("saved", "", ["saved"]); }
|
||||
|
||||
// ---- VPN
|
||||
|
||||
function importVpn(path: string): void {
|
||||
@@ -273,16 +326,34 @@ Singleton {
|
||||
// above: only this one ever opens stdin.
|
||||
function joinEnterprise(ssid: string, profile: string, identity: string,
|
||||
password: string, caCert: string): void {
|
||||
if (enterprise.running)
|
||||
if (passwordJoin.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
root.pendingPassword = password;
|
||||
enterprise.subject = ssid;
|
||||
enterprise.command = String(caCert ?? "") !== ""
|
||||
passwordJoin.subject = ssid;
|
||||
passwordJoin.command = String(caCert ?? "") !== ""
|
||||
? [root.helperPath, "join-enterprise", ssid, profile, identity, caCert]
|
||||
: [root.helperPath, "join-enterprise", ssid, profile, identity];
|
||||
enterprise.stdinEnabled = true;
|
||||
enterprise.running = true;
|
||||
passwordJoin.stdinEnabled = true;
|
||||
passwordJoin.running = true;
|
||||
}
|
||||
|
||||
// ---- hidden Wi-Fi
|
||||
//
|
||||
// Same passphrase path as the enterprise join, for the same reason: a
|
||||
// passphrase in argv is published to every process on this machine through
|
||||
// /proc. An open hidden network still goes down this path and writes an
|
||||
// empty line, so the helper's read returns rather than waiting forever.
|
||||
function joinHidden(ssid: string, profile: string, security: string,
|
||||
password: string): void {
|
||||
if (passwordJoin.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
root.pendingPassword = password;
|
||||
passwordJoin.subject = profile;
|
||||
passwordJoin.command = [root.helperPath, "join-hidden", ssid, profile, security];
|
||||
passwordJoin.stdinEnabled = true;
|
||||
passwordJoin.running = true;
|
||||
}
|
||||
|
||||
// Everything that is not per-connection, in one call: what a page asks for
|
||||
@@ -291,6 +362,7 @@ Singleton {
|
||||
root.refreshProxy();
|
||||
root.refreshAirplaneSoon();
|
||||
root.refreshHotspotSoon();
|
||||
root.refreshSavedSoon();
|
||||
for (const connection in root.details)
|
||||
root.requestDetails(connection);
|
||||
}
|
||||
@@ -299,6 +371,7 @@ Singleton {
|
||||
// over it rather than dropped by its running guard.
|
||||
function refreshAirplaneSoon(): void { airplaneSoon.restart(); }
|
||||
function refreshHotspotSoon(): void { hotspotSoon.restart(); }
|
||||
function refreshSavedSoon(): void { savedSoon.restart(); }
|
||||
|
||||
onActiveChanged: if (root.active) root.refresh()
|
||||
|
||||
@@ -324,6 +397,17 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: savedSoon
|
||||
interval: 400
|
||||
onTriggered: {
|
||||
if (mutation.running)
|
||||
savedSoon.restart();
|
||||
else
|
||||
root.refreshSaved();
|
||||
}
|
||||
}
|
||||
|
||||
// The next queued details read, one tick after the last one exits. Draining
|
||||
// from inside onExited would look at a `running` that has not gone false
|
||||
// yet, and the queue would stall on its own guard.
|
||||
@@ -369,24 +453,29 @@ Singleton {
|
||||
}
|
||||
|
||||
Process {
|
||||
id: enterprise
|
||||
id: passwordJoin
|
||||
|
||||
property string subject: ""
|
||||
|
||||
onStarted: {
|
||||
enterprise.write(root.pendingPassword + "\n");
|
||||
passwordJoin.write(root.pendingPassword + "\n");
|
||||
// Held for as long as it takes to hand over, and no longer.
|
||||
root.pendingPassword = "";
|
||||
// Closing stdin is what lets the helper's read return; without it
|
||||
// the join waits forever for a line that is already sent.
|
||||
enterprise.stdinEnabled = false;
|
||||
passwordJoin.stdinEnabled = false;
|
||||
}
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.absorbDetails(enterprise.subject, this.text)
|
||||
onStreamFinished: root.absorbDetails(passwordJoin.subject, this.text)
|
||||
}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
}
|
||||
onExited: root.pendingPassword = ""
|
||||
onExited: {
|
||||
root.pendingPassword = "";
|
||||
// A join makes a profile, so the saved list is out of date the
|
||||
// moment this returns.
|
||||
root.refreshSavedSoon();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@ Singleton {
|
||||
// Settings that are real but have no schema entry, because the system owns
|
||||
// them rather than Panama. Without these, searching "timezone" would fail
|
||||
// on a settings app that plainly has one.
|
||||
//
|
||||
// An entry may name a `section` as well as a page. A page with tabs opens
|
||||
// on the tab holding the thing that was searched for, rather than on
|
||||
// whichever tab it opens on by default -- see SettingsSidebar. Sections are
|
||||
// only worth naming for a page that consumes one (ShellState.takeSettings-
|
||||
// Section); everything else leaves it off and routes as before.
|
||||
readonly property var extraEntries: [
|
||||
{ label: "Manual", detail: "How this desktop works, in chapters", page: "manual" },
|
||||
{ label: "Getting started", detail: "Coming from GNOME, macOS or Windows", page: "manual" },
|
||||
@@ -92,6 +98,22 @@ Singleton {
|
||||
{ label: "IP address", detail: "The address, gateway, DNS servers, and hardware address of a connection", page: "connectivity" },
|
||||
{ label: "Forget a Wi-Fi network", detail: "Remove a saved network so it stops connecting on its own", page: "connectivity" },
|
||||
{ label: "Enterprise Wi-Fi", detail: "Join a network that asks for an identity and a password", page: "connectivity" },
|
||||
// Tier 2 network truths. Each of these was a reason to open a terminal
|
||||
// or GNOME's panel, and none of them is the label of a preference: a
|
||||
// static address is a profile property, a saved network is a file
|
||||
// NetworkManager keeps, and metered is a flag on both.
|
||||
{ label: "Saved networks", detail: "Every network this machine remembers, including the ones nowhere near you, and forgetting one", page: "connectivity" },
|
||||
{ label: "Join a hidden network", detail: "A network that does not broadcast its name — type the name and its security", page: "connectivity" },
|
||||
{ label: "Hidden network", detail: "Join a Wi-Fi network that does not announce itself", page: "connectivity" },
|
||||
{ label: "Metered connection", detail: "Mark a connection as costing money by the byte, so updates and large downloads wait", page: "connectivity" },
|
||||
{ label: "Static IP address", detail: "Set a manual IPv4 or IPv6 address, gateway and DNS for one connection", page: "connectivity" },
|
||||
{ label: "Manual IP address", detail: "Turn off DHCP for a connection and enter the address yourself", page: "connectivity" },
|
||||
{ label: "DNS servers", detail: "The nameservers a connection uses, and replacing the ones it is handed", page: "connectivity" },
|
||||
{ label: "Show the Wi-Fi password", detail: "A QR code a phone can scan, so nobody has to read the password out", page: "connectivity" },
|
||||
// The two words people type for the same socket. Neither appears in the
|
||||
// page's own labels, which say "Wired" once and "Ethernet" once.
|
||||
{ label: "Wired network", detail: "The Ethernet connection, its link speed, and its addresses", page: "connectivity" },
|
||||
{ label: "Ethernet", detail: "The wired connection: turn it off, or open its addresses", page: "connectivity" },
|
||||
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
|
||||
// The Applications tab manages applications now, rather than only
|
||||
// pointing file types at them, so the things people come looking for —
|
||||
@@ -264,6 +286,11 @@ Singleton {
|
||||
{ label: "Pinned applications", detail: "Reorder the dock by dragging, here or on the dock itself", page: "dock" },
|
||||
{ label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" },
|
||||
{ label: "Control Center sections", detail: "Choose what the panel offers", page: "control-center" },
|
||||
{ label: "Do Not Disturb tile", detail: "The Control Center switch that holds banners back, beside Presentation", page: "control-center" },
|
||||
// Signing out has no preference anywhere: it is a power-menu verb, and
|
||||
// "log out" returned nothing at all on a desktop that plainly does it.
|
||||
{ label: "Log out", detail: "Sign out of this session from the power menu — the same menu that restarts and powers off", page: "power" },
|
||||
{ label: "Sign out", detail: "End this session and return to the login screen", page: "power" },
|
||||
{ label: "Do Not Disturb", detail: "Hold banners back until you turn it off", page: "notifications" },
|
||||
{ label: "Quiet hours", detail: "The schedule the Sleep focus mode keeps", page: "notifications" },
|
||||
{ label: "Critical alerts break through", detail: "Let urgent notifications past Do Not Disturb", page: "notifications" },
|
||||
@@ -277,6 +304,18 @@ Singleton {
|
||||
// a row — so searching for the thing people actually want to do would
|
||||
// otherwise find only the shortcut it is being done to.
|
||||
{ label: "Rebind a shortcut", detail: "Change the keys an action answers to, or put them back", page: "shortcuts" },
|
||||
// Tier 2. A custom shortcut, an app rule and a gesture assignment are
|
||||
// all the same thing wearing three hats -- a named action -- and none
|
||||
// of the three is a preference with a label to find it by.
|
||||
{ label: "Custom shortcut", detail: "Bind your own keys to an application, a shell action, or a window action", page: "shortcuts" },
|
||||
{ label: "Add a shortcut", detail: "Press the chord you want, then pick what it should do", page: "shortcuts" },
|
||||
{ label: "Launch an app with a shortcut", detail: "Give an application its own key combination", page: "shortcuts" },
|
||||
{ label: "App rules", detail: "Per-application window rules: float, centre, size, workspace, and no dimming", page: "tiling" },
|
||||
{ label: "Window rules", detail: "Make one application always float, open on a workspace, or skip the animations", page: "tiling" },
|
||||
{ label: "Always float a window", detail: "A per-application rule, so one application stops being tiled", page: "tiling" },
|
||||
{ label: "Gestures", detail: "Three-finger swipes as shipped, and four-finger swipes you assign yourself", page: "mouse" },
|
||||
{ label: "Touchpad gestures", detail: "What swiping with three or four fingers does", page: "mouse" },
|
||||
{ label: "Four-finger swipe", detail: "Assign an application or a shell action to each direction", page: "mouse" },
|
||||
{ label: "Pointer test area", detail: "Scribble and scroll to feel a pointer change before keeping it", page: "mouse" },
|
||||
{ label: "Connected input devices", detail: "The keyboards, mice, and touchpad this machine can see", page: "mouse" },
|
||||
// Accessibility. The schema covers the switches by their own labels, so
|
||||
@@ -294,13 +333,18 @@ Singleton {
|
||||
{ label: "Screen reader", detail: "Start Orca and see whether the accessibility bus is up", page: "accessibility" },
|
||||
{ label: "Orca", detail: "The screen reader: whether it is running, and starting or stopping it", page: "accessibility" },
|
||||
{ label: "Sticky keys", detail: "Why sticky, slow and bounce keys are not offered in this session", page: "accessibility" },
|
||||
// The colour filter is a compositor shader rather than a switch, so it
|
||||
// is findable by the condition rather than only by the word "filter".
|
||||
{ label: "Color filter", detail: "A whole-screen filter the compositor renders: grayscale, or one for each kind of colour blindness", page: "accessibility" },
|
||||
{ label: "Grayscale", detail: "Drain the colour out of the whole screen", page: "accessibility" },
|
||||
{ label: "Color blindness", detail: "Protanopia, deuteranopia and tritanopia filters applied to the whole screen", page: "accessibility" },
|
||||
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
|
||||
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
|
||||
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
|
||||
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" },
|
||||
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid color", page: "appearance" },
|
||||
{ label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance" },
|
||||
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" },
|
||||
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid color", page: "appearance", section: "background" },
|
||||
{ label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance", section: "background" },
|
||||
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance", section: "background" },
|
||||
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
|
||||
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
|
||||
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" },
|
||||
@@ -316,18 +360,18 @@ Singleton {
|
||||
{ label: "Mirror displays", detail: "Show the same picture on a second display", page: "displays" },
|
||||
{ label: "Variable refresh rate", detail: "Override the gaming policy for one display", page: "displays" },
|
||||
{ label: "Monitor brightness", detail: "The monitor's own backlight, over DDC", page: "displays" },
|
||||
{ label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" },
|
||||
{ label: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance" },
|
||||
{ label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance" },
|
||||
{ label: "Light mode", detail: "Flip the desktop to your light theme", page: "appearance" },
|
||||
{ label: "Theme editor", detail: "Build your own theme — colors, saturation, and effects", page: "appearance" },
|
||||
{ label: "Catppuccin", detail: "Mocha and Latte, in the theme galleries", page: "appearance" },
|
||||
{ label: "Nord", detail: "The arctic dark theme, in the gallery", page: "appearance" },
|
||||
{ label: "Gruvbox", detail: "Dark and light, in the theme galleries", page: "appearance" },
|
||||
{ label: "Everforest", detail: "Dark and light, in the theme galleries", page: "appearance" },
|
||||
{ label: "Tokyo Night", detail: "Moon and Day, the shipped defaults", page: "appearance" },
|
||||
{ label: "Video wallpaper", detail: "A looping video as the desktop background", page: "appearance" },
|
||||
{ label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance" },
|
||||
{ label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance", section: "themes" },
|
||||
{ label: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance", section: "themes" },
|
||||
{ label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance", section: "themes" },
|
||||
{ label: "Light mode", detail: "Flip the desktop to your light theme", page: "appearance", section: "themes" },
|
||||
{ label: "Theme editor", detail: "Build your own theme — colors, saturation, and effects", page: "appearance", section: "editor" },
|
||||
{ label: "Catppuccin", detail: "Mocha and Latte, in the theme galleries", page: "appearance", section: "themes" },
|
||||
{ label: "Nord", detail: "The arctic dark theme, in the gallery", page: "appearance", section: "themes" },
|
||||
{ label: "Gruvbox", detail: "Dark and light, in the theme galleries", page: "appearance", section: "themes" },
|
||||
{ label: "Everforest", detail: "Dark and light, in the theme galleries", page: "appearance", section: "themes" },
|
||||
{ label: "Tokyo Night", detail: "Moon and Day, the shipped defaults", page: "appearance", section: "themes" },
|
||||
{ label: "Video wallpaper", detail: "A looping video as the desktop background", page: "appearance", section: "background" },
|
||||
{ label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance", section: "editor" },
|
||||
{ label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" }
|
||||
]
|
||||
|
||||
@@ -335,41 +379,77 @@ Singleton {
|
||||
return root.groupPages[group] ?? "home";
|
||||
}
|
||||
|
||||
// [{ label, detail, page, kind }] for a query. Empty query yields nothing:
|
||||
// the sidebar shows its normal navigation in that case.
|
||||
// Both sides of a comparison, in the one spelling.
|
||||
//
|
||||
// The hyphens go because they are a typographic choice rather than a word
|
||||
// boundary, and the desktop's most-searched noun is the worst case:
|
||||
// everything here spells it "Wi-Fi" and nobody types it that way, so "wifi"
|
||||
// matched nothing at all. Applied to the query and the index alike, so the
|
||||
// rule is a spelling equivalence rather than a special case for one word.
|
||||
function flatten(text: string): string {
|
||||
return String(text).trim().toLowerCase().replace(/-/g, "");
|
||||
}
|
||||
|
||||
// [{ label, detail, page, kind, section }] for a query. Empty query yields
|
||||
// nothing: the sidebar shows its normal navigation in that case.
|
||||
//
|
||||
// The needle is split on whitespace and every token has to appear somewhere
|
||||
// in the haystack -- an AND over words rather than one contiguous
|
||||
// substring. The old shape matched the whole query as typed, so "wifi
|
||||
// password", "log out" and "metered" returned nothing on a settings app
|
||||
// that has all three: the words are all present, just not adjacent and not
|
||||
// in that order. Order was the accidental part, and it was doing the most
|
||||
// damage.
|
||||
function search(query: string): var {
|
||||
const needle = String(query).trim().toLowerCase();
|
||||
const needle = root.flatten(query);
|
||||
if (needle === "")
|
||||
return [];
|
||||
|
||||
const tokens = needle.split(/\s+/).filter(token => token !== "");
|
||||
if (tokens.length === 0)
|
||||
return [];
|
||||
|
||||
function hit(haystack) {
|
||||
const text = root.flatten(haystack);
|
||||
return tokens.every(token => text.indexOf(token) >= 0);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const seen = {};
|
||||
|
||||
function add(label, detail, page, kind) {
|
||||
function add(label, detail, page, kind, section) {
|
||||
const dedupe = `${kind}:${label}:${page}`;
|
||||
if (seen[dedupe])
|
||||
return;
|
||||
seen[dedupe] = true;
|
||||
results.push({ label: label, detail: detail, page: page, kind: kind });
|
||||
results.push({
|
||||
label: label,
|
||||
detail: detail,
|
||||
page: page,
|
||||
kind: kind,
|
||||
// "" means "the page's own first tab", which is every result
|
||||
// that does not name one. See SettingsSidebar.
|
||||
section: String(section ?? "")
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of PreferenceSchema.entries) {
|
||||
if (entry.internal)
|
||||
continue;
|
||||
const optionLabels = (entry.options ?? []).map(option => option.label).join(" ");
|
||||
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`.toLowerCase();
|
||||
if (haystack.indexOf(needle) >= 0)
|
||||
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting");
|
||||
const haystack = `${entry.label} ${entry.detail ?? ""} ${entry.group} ${optionLabels}`;
|
||||
if (hit(haystack))
|
||||
add(entry.label, entry.detail ?? "", root.pageFor(entry.group), "setting", "");
|
||||
}
|
||||
|
||||
for (const entry of root.extraEntries) {
|
||||
if (`${entry.label} ${entry.detail}`.toLowerCase().indexOf(needle) >= 0)
|
||||
add(entry.label, entry.detail, entry.page, "setting");
|
||||
if (hit(`${entry.label} ${entry.detail}`))
|
||||
add(entry.label, entry.detail, entry.page, "setting", entry.section ?? "");
|
||||
}
|
||||
|
||||
for (const bind of Keybinds.binds) {
|
||||
if (bind.description.toLowerCase().indexOf(needle) >= 0)
|
||||
add(bind.description, bind.chord, "shortcuts", "shortcut");
|
||||
if (hit(bind.description))
|
||||
add(bind.description, bind.chord, "shortcuts", "shortcut", "");
|
||||
}
|
||||
|
||||
// Exact prefix matches first: typing "blur" should put "Blur" above
|
||||
@@ -377,15 +457,25 @@ Singleton {
|
||||
// its explanation. An exact enum option also leads: "slideshow" is a
|
||||
// mode choice, so Wallpaper mode belongs above the interval row that
|
||||
// merely explains it.
|
||||
//
|
||||
// Between those two comes the tokenized rule: a row whose LABEL holds
|
||||
// every word of the query beats one that only holds some of them, or
|
||||
// holds them in its explanation. Without it, "wifi password" would rank
|
||||
// every row whose detail happens to say "password" alongside the rows
|
||||
// that are actually about the Wi-Fi password.
|
||||
return results.sort((a, b) => {
|
||||
const aSpec = PreferenceSchema.entries.find(entry => entry.label === a.label);
|
||||
const bSpec = PreferenceSchema.entries.find(entry => entry.label === b.label);
|
||||
const ao = (aSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
|
||||
const bo = (bSpec?.options ?? []).some(option => option.label.toLowerCase() === needle);
|
||||
const ao = (aSpec?.options ?? []).some(option => root.flatten(option.label) === needle);
|
||||
const bo = (bSpec?.options ?? []).some(option => root.flatten(option.label) === needle);
|
||||
if (ao !== bo)
|
||||
return ao ? -1 : 1;
|
||||
const al = a.label.toLowerCase();
|
||||
const bl = b.label.toLowerCase();
|
||||
const al = root.flatten(a.label);
|
||||
const bl = root.flatten(b.label);
|
||||
const at = tokens.every(token => al.indexOf(token) >= 0) ? 0 : 1;
|
||||
const bt = tokens.every(token => bl.indexOf(token) >= 0) ? 0 : 1;
|
||||
if (at !== bt)
|
||||
return at - bt;
|
||||
const ap = al === needle ? 0 : (al.indexOf(needle) === 0 ? 1 : 2);
|
||||
const bp = bl === needle ? 0 : (bl.indexOf(needle) === 0 ? 1 : 2);
|
||||
return ap !== bp ? ap - bp : al.localeCompare(bl);
|
||||
|
||||
@@ -652,6 +652,81 @@ Singleton {
|
||||
root.applyOptions({ directScanoutPolicy: policy });
|
||||
}
|
||||
|
||||
// ── Color filters ───────────────────────────────────────────────────────
|
||||
// The stored preference is an enum; what the compositor wants is a shader
|
||||
// path. That mapping cannot be a `hypr:` block on the schema entry -- the
|
||||
// read-back would compare "grayscale" against a filename and fail every
|
||||
// shape and sweep contract -- so it lives here, and hypr/looks.lua does the
|
||||
// same lookup for the value the config carries at launch.
|
||||
//
|
||||
// The shaders are installed by the hypr directory symlink, so the path is
|
||||
// the deployed one rather than the repository's.
|
||||
readonly property string shaderDir:
|
||||
(Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/hypr/shaders"
|
||||
|
||||
readonly property var colorFilterShaders: ({
|
||||
"grayscale": "grayscale.frag",
|
||||
"protanopia": "protanopia.frag",
|
||||
"deuteranopia": "deuteranopia.frag",
|
||||
"tritanopia": "tritanopia.frag"
|
||||
})
|
||||
|
||||
// "" for none, and for any value this build does not ship a shader for --
|
||||
// an unknown filter turns the filter off rather than leaving the previous
|
||||
// one on under a new name.
|
||||
function colorFilterPath(name: string): string {
|
||||
const file = root.colorFilterShaders[String(name)];
|
||||
return file === undefined ? "" : `${root.shaderDir}/${file}`;
|
||||
}
|
||||
|
||||
property string colorFilterPending: ""
|
||||
|
||||
Process {
|
||||
id: colorFilterWrite
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
// `hyprctl eval` exits 0 on a Lua error and reports it on
|
||||
// stdout, so the exit status proves nothing. Read it back.
|
||||
if (this.text.indexOf("error:") >= 0) {
|
||||
root.lastError = "The color filter could not be applied.";
|
||||
return;
|
||||
}
|
||||
colorFilterVerify.exec(["hyprctl", "-j", "getoption", "decoration:screen_shader"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: colorFilterVerify
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const observed = String(JSON.parse(this.text).str ?? "");
|
||||
if (observed !== root.colorFilterPending) {
|
||||
root.lastError = "The compositor did not take the color filter.";
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.lastError = "The compositor did not say whether the color filter applied.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Applies the filter to the running compositor. The preference is written
|
||||
// by the row that calls this; nothing here stores anything.
|
||||
function applyColorFilter(name: string): void {
|
||||
if (colorFilterWrite.running)
|
||||
return;
|
||||
const path = root.colorFilterPath(name);
|
||||
root.colorFilterPending = path;
|
||||
colorFilterWrite.exec(["hyprctl", "eval",
|
||||
`hl.config({ decoration = { screen_shader = "${path.replace(/["\\]/g, "")}" } })`]);
|
||||
}
|
||||
|
||||
// Replays every compositor-owned preference in one batch at shell start, so
|
||||
// a value the user changed in Settings survives a reboot even though the
|
||||
// Lua config only reads the file once, at launch.
|
||||
|
||||
@@ -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