Hyprland 0.57 refuses a bind on a bare modifier and an empty input:scroll_method. The switcher's Super-release commit now binds Super_L and Super_R, which also brings back every bind below it in keybinds.lua. An empty scroll method is left unwritten, so libinput still picks per device.
644 lines
27 KiB
QML
644 lines
27 KiB
QML
pragma Singleton
|
|
|
|
// The keymap, read from the compositor rather than restated.
|
|
//
|
|
// The Shortcuts page used to hold a hand-typed array of nineteen entries while
|
|
// keybinds.lua produced a hundred and thirteen. It could not show the other
|
|
// ninety-four, and it drifted the moment a bind was edited. `hyprctl binds -j`
|
|
// is the only description of the keymap that cannot be wrong, so this reads
|
|
// that and every bind carries its own human label (see the `description`
|
|
// argument in hypr/keybinds.lua).
|
|
//
|
|
// Refreshed on demand, not polled: binds only change when the config is
|
|
// reloaded, and nothing in this shell should wake up to re-read something that
|
|
// has not moved.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
import qs.config
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
// [{ chord, description, group, mouse, repeating, locked }], ordered as
|
|
// Hyprland reports them, which follows the order they appear in the config.
|
|
property var binds: []
|
|
property bool loaded: false
|
|
property string lastError: ""
|
|
|
|
readonly property bool busy: query.running
|
|
|
|
// Hyprland's modmask bits. SUPER is the Panama modifier.
|
|
readonly property var modifierBits: [
|
|
{ bit: 64, name: "Super" },
|
|
{ bit: 4, name: "Ctrl" },
|
|
{ bit: 8, name: "Alt" },
|
|
{ bit: 1, name: "Shift" }
|
|
]
|
|
|
|
// Keysyms whose raw names would be noise in a shortcuts list.
|
|
readonly property var keyNames: ({
|
|
"mouse_up": "Scroll up",
|
|
"mouse_down": "Scroll down",
|
|
"mouse:272": "Left click",
|
|
"mouse:273": "Right click",
|
|
"mouse:274": "Middle click",
|
|
"bracketleft": "[",
|
|
"bracketright": "]",
|
|
"grave": "`",
|
|
"slash": "/",
|
|
"backslash": "\\",
|
|
"period": ".",
|
|
"comma": ",",
|
|
"equal": "=",
|
|
"minus": "-",
|
|
"semicolon": ";",
|
|
"apostrophe": "'",
|
|
"Return": "Enter",
|
|
"Escape": "Esc",
|
|
"space": "Space",
|
|
"Print": "Print Screen",
|
|
"left": "←",
|
|
"right": "→",
|
|
"up": "↑",
|
|
"down": "↓"
|
|
})
|
|
|
|
Process {
|
|
id: query
|
|
command: ["hyprctl", "-j", "binds"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.parse(this.text)
|
|
}
|
|
onExited: (exitCode, exitStatus) => {
|
|
if (exitCode !== 0)
|
|
root.lastError = "Could not read the keymap from Hyprland.";
|
|
}
|
|
}
|
|
|
|
// ── Rebinding ───────────────────────────────────────────────────────────
|
|
// Overrides map a SHIPPED chord to a replacement. hypr/keybinds.lua reads
|
|
// them and substitutes only the chord -- the action is always the Lua value
|
|
// written in that file -- so an override can move a shortcut but can never
|
|
// make one do something else.
|
|
//
|
|
// Applying needs `hyprctl reload` rather than a live `hl.bind`: Hyprland
|
|
// reports Lua-defined binds with dispatcher "__lua" and a bytecode offset,
|
|
// so the action cannot be reconstructed from the outside to re-bind it.
|
|
// Reload re-runs the config, which re-reads the settings file.
|
|
readonly property var overrides: {
|
|
const stored = DesktopPreferences.get("keybindOverrides");
|
|
return (stored && typeof stored === "object") ? stored : ({});
|
|
}
|
|
|
|
property bool reloading: false
|
|
|
|
// The chord a bind ships with, given the chord it currently answers to.
|
|
// 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;
|
|
}
|
|
return currentChord;
|
|
}
|
|
|
|
function isOverridden(currentChord: string): bool {
|
|
return root.shippedChordFor(currentChord) !== currentChord;
|
|
}
|
|
|
|
// Refuses a chord already answering to something else, so rebinding cannot
|
|
// quietly shadow an existing shortcut.
|
|
function conflictFor(chord: string, exceptCurrent: string): string {
|
|
for (const bind of root.binds) {
|
|
if (bind.luaChord === chord && bind.luaChord !== exceptCurrent)
|
|
return bind.description;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// A moved shortcut vacates its shipped chord, so another override may use
|
|
// it legitimately. Refuse to reset the first shortcut until that occupant
|
|
// moves away; otherwise Hyprland would receive two binds on one chord.
|
|
function overrideOccupantFor(chord: string, exceptShipped: string): string {
|
|
for (const shipped in root.overrides) {
|
|
if (shipped !== exceptShipped && root.overrides[shipped] === chord)
|
|
return shipped;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
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;
|
|
|
|
const conflict = root.conflictFor(newChord, currentChord);
|
|
if (conflict !== "") {
|
|
root.lastError = `${newChord} is already ${conflict}.`;
|
|
return false;
|
|
}
|
|
|
|
const shipped = root.shippedChordFor(currentChord);
|
|
const next = Object.assign({}, root.overrides);
|
|
if (newChord === shipped)
|
|
delete next[shipped];
|
|
else
|
|
next[shipped] = newChord;
|
|
|
|
if (!DesktopPreferences.set("keybindOverrides", next)) {
|
|
root.lastError = "That shortcut could not be saved.";
|
|
return false;
|
|
}
|
|
root.applyReload();
|
|
return true;
|
|
}
|
|
|
|
function resetBind(currentChord: string): bool {
|
|
const shipped = root.shippedChordFor(currentChord);
|
|
if (shipped === currentChord)
|
|
return true;
|
|
|
|
const occupant = root.overrideOccupantFor(shipped, shipped);
|
|
if (occupant !== "") {
|
|
root.lastError = `${shipped} is used by another rebound shortcut. Reset that shortcut first.`;
|
|
return false;
|
|
}
|
|
|
|
const next = Object.assign({}, root.overrides);
|
|
delete next[shipped];
|
|
if (!DesktopPreferences.set("keybindOverrides", next)) {
|
|
root.lastError = "That shortcut could not be reset.";
|
|
return false;
|
|
}
|
|
root.applyReload();
|
|
return true;
|
|
}
|
|
|
|
function resetAll(): void {
|
|
if (Object.keys(root.overrides).length === 0)
|
|
return;
|
|
DesktopPreferences.set("keybindOverrides", ({}));
|
|
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"]
|
|
onExited: (exitCode, exitStatus) => {
|
|
root.reloading = false;
|
|
if (exitCode !== 0) {
|
|
root.lastError = "The compositor did not reload.";
|
|
return;
|
|
}
|
|
root.lastError = "";
|
|
// The settings file is written on a timer, so re-read the keymap
|
|
// once the reload has had a moment to pick it up.
|
|
settle.restart();
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: settle
|
|
interval: 350
|
|
onTriggered: root.refresh()
|
|
}
|
|
|
|
function applyReload(): void {
|
|
if (reloadRun.running)
|
|
return;
|
|
root.reloading = true;
|
|
// Give DesktopPreferences' coalescing write a moment to land first.
|
|
reloadDelay.restart();
|
|
}
|
|
|
|
Timer {
|
|
id: reloadDelay
|
|
interval: 120
|
|
onTriggered: reloadRun.running = true
|
|
}
|
|
|
|
Component.onCompleted: root.refresh()
|
|
|
|
function refresh(): void {
|
|
if (query.running)
|
|
return;
|
|
root.lastError = "";
|
|
query.running = true;
|
|
}
|
|
|
|
function parse(text: string): void {
|
|
try {
|
|
const raw = JSON.parse(text);
|
|
const out = [];
|
|
for (const bind of raw) {
|
|
const description = String(bind.description ?? "").trim();
|
|
// A bind with no description cannot be presented usefully --
|
|
// the dispatcher is "__lua" and the argument is a bytecode
|
|
// offset. Showing the chord alone would be worse than omitting
|
|
// it, and tests/quickshell/keybinds-contract fails the build
|
|
// if any exist, so this should never be reached in practice.
|
|
if (description === "")
|
|
continue;
|
|
out.push({
|
|
chord: root.formatChord(bind),
|
|
// The same chord in the form hypr/keybinds.lua writes, which
|
|
// is what an override is keyed by. The display form
|
|
// prettifies modifiers and arrow keys and so cannot be used
|
|
// for that.
|
|
luaChord: root.luaChord(bind),
|
|
description: description,
|
|
group: root.groupFor(description, bind),
|
|
mouse: bind.mouse === true,
|
|
repeating: bind.repeat === true,
|
|
locked: bind.locked === true
|
|
});
|
|
}
|
|
root.binds = out;
|
|
root.loaded = true;
|
|
root.lastError = "";
|
|
} catch (error) {
|
|
root.lastError = "The keymap could not be read.";
|
|
}
|
|
}
|
|
|
|
// "SUPER + SHIFT + K" -- uppercase modifiers in the order keybinds.lua
|
|
// writes them, then the raw keysym rather than its display name.
|
|
function luaChord(bind: var): string {
|
|
const parts = [];
|
|
for (const modifier of root.modifierBits) {
|
|
if ((bind.modmask & modifier.bit) !== 0)
|
|
parts.push(modifier.name.toUpperCase());
|
|
}
|
|
// Guard against a bind with no key. Hyprland 0.57 refuses to create
|
|
// one (the switcher's Super-release commit now names Super_L/Super_R),
|
|
// but appending an empty string would leave a dangling "SUPER + " that
|
|
// matches neither the chord hypr/keybinds.lua binds nor the one an
|
|
// override would be keyed by.
|
|
const key = String(bind.key ?? "");
|
|
if (key !== "")
|
|
parts.push(key);
|
|
return parts.join(" + ");
|
|
}
|
|
|
|
function formatChord(bind: var): string {
|
|
const parts = [];
|
|
for (const modifier of root.modifierBits) {
|
|
if ((bind.modmask & modifier.bit) !== 0)
|
|
parts.push(modifier.name);
|
|
}
|
|
const key = String(bind.key ?? "");
|
|
if (key !== "")
|
|
parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key));
|
|
return parts.join(" + ");
|
|
}
|
|
|
|
// Grouping is by what the shortcut does, taken from its own description,
|
|
// so adding a bind puts it in the right section without touching this file.
|
|
// Which section a bind belongs to.
|
|
//
|
|
// "Windows" used to catch focus, movement, splitting, resizing and window
|
|
// state alike, which put 43 of the 93 binds under one heading -- a section
|
|
// that long is a list, not a grouping. The window verbs are separated here
|
|
// by what you are actually trying to do.
|
|
//
|
|
// Order matters: "Next window splits down" is about splitting rather than
|
|
// focus, and "Focus session" is a Panama feature rather than window focus,
|
|
// so both are settled before the general checks below them.
|
|
// What hypr/keybinds.lua says this bind is for, when it has said anything.
|
|
// Written at config load to a manifest keyed by the chord actually bound,
|
|
// because Hyprland reports a Lua bind's dispatcher as `__lua` with a
|
|
// bytecode offset and nothing can be attached to a bind that survives into
|
|
// `hyprctl binds`.
|
|
property var categoryManifest: ({})
|
|
|
|
FileView {
|
|
path: (Quickshell.env("XDG_STATE_HOME") || `${Quickshell.env("HOME")}/.local/state`)
|
|
+ "/panama/keybind-categories.json"
|
|
printErrors: false
|
|
watchChanges: true
|
|
onFileChanged: this.reload()
|
|
onLoaded: {
|
|
try {
|
|
const parsed = JSON.parse(this.text());
|
|
root.categoryManifest = (parsed && typeof parsed === "object") ? parsed : ({});
|
|
} catch (error) {
|
|
root.categoryManifest = ({});
|
|
}
|
|
}
|
|
// No manifest is the normal state on a machine whose compositor config
|
|
// has not been reloaded since this was added. The substring derivation
|
|
// below still produces groups, so the keymap page and the cheatsheet
|
|
// work; they are just grouped by guesswork until the next reload.
|
|
onLoadFailed: root.categoryManifest = ({})
|
|
}
|
|
|
|
function groupFor(description: string, bind: var): string {
|
|
// The authored category wins. Keyed by the raw chord, which is what
|
|
// the manifest records and what Hyprland reports.
|
|
const authored = root.categoryManifest[root.luaChord(bind)];
|
|
if (typeof authored === "string" && authored !== "")
|
|
return authored;
|
|
|
|
const text = description.toLowerCase();
|
|
if (bind.key && String(bind.key).indexOf("XF86") === 0)
|
|
return "Media & hardware keys";
|
|
|
|
// Quiet mode and Caffeine bound to a workspace, not window focus.
|
|
if (text.indexOf("focus session") >= 0)
|
|
return "Applications & shell";
|
|
|
|
if (text.indexOf("workspace") >= 0)
|
|
return "Workspaces";
|
|
|
|
if (text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|
|
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|
|
|| text.indexOf("shrink") >= 0 || text.indexOf("expand") >= 0
|
|
|| text.indexOf("grow") >= 0 || text.indexOf("resize") >= 0)
|
|
return "Size";
|
|
|
|
if (text.indexOf("split") >= 0 || text.indexOf("swap") >= 0
|
|
|| text.indexOf("move window") >= 0)
|
|
return "Move & split";
|
|
|
|
if (text.indexOf("close") >= 0 || text.indexOf("fullscreen") >= 0
|
|
|| text.indexOf("float") >= 0 || text.indexOf("pin ") >= 0
|
|
|| text.indexOf("scratchpad") >= 0 || text.indexOf("minimize") >= 0)
|
|
return "Window state";
|
|
|
|
if (text.indexOf("focus") >= 0 || text.indexOf("next window") >= 0
|
|
|| text.indexOf("previous window") >= 0 || text.indexOf("last window") >= 0
|
|
|| text.indexOf("window switch") >= 0)
|
|
return "Focus";
|
|
|
|
if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0
|
|
|| text.indexOf("track") >= 0 || text.indexOf("play") >= 0
|
|
|| text.indexOf("brightness") >= 0)
|
|
return "Media & hardware keys";
|
|
|
|
return "Applications & shell";
|
|
}
|
|
|
|
// Section order for the page. Anything a future bind invents lands at the
|
|
// end rather than being dropped.
|
|
// The authored categories come first, in the order somebody learning this
|
|
// desktop would want them: what you do to a window, then to a workspace,
|
|
// then how you start things, then the shell's own surfaces. The names
|
|
// 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.
|
|
// 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"]
|
|
|
|
// The action already bound to a chord, or "" if it is free. Compared on the
|
|
// form keybinds.lua writes rather than the prettified display form, because
|
|
// that is what a rebind is keyed by -- "SUPER + Q" and "Super+Q" are the
|
|
// same binding and must not read as two.
|
|
function boundTo(luaChord: string, exceptLuaChord: string): string {
|
|
const wanted = String(luaChord).replace(/\s+/g, "").toLowerCase();
|
|
const skip = String(exceptLuaChord).replace(/\s+/g, "").toLowerCase();
|
|
for (const bind of root.binds) {
|
|
const candidate = String(bind.luaChord).replace(/\s+/g, "").toLowerCase();
|
|
if (candidate === wanted && candidate !== skip)
|
|
return String(bind.description);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function grouped(): var {
|
|
const buckets = {};
|
|
for (const bind of root.binds) {
|
|
buckets[bind.group] = buckets[bind.group] ?? [];
|
|
buckets[bind.group].push(bind);
|
|
}
|
|
const names = Object.keys(buckets).sort((a, b) => {
|
|
const ia = root.groupOrder.indexOf(a);
|
|
const ib = root.groupOrder.indexOf(b);
|
|
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
|
|
});
|
|
return names.map(name => ({ name: name, binds: buckets[name] }));
|
|
}
|
|
}
|