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"]
|
||||
|
||||
Reference in New Issue
Block a user