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());
|
|
}
|
|
// A modifier-only bind has no key at all -- the window switcher commits
|
|
// on Super RELEASE. Appending an empty string left a dangling "SUPER + "
|
|
// that matched neither the chord hypr/keybinds.lua binds nor the one an
|
|
// override would be keyed by, so that bind could never be rebound and
|
|
// never found its category.
|
|
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] }));
|
|
}
|
|
}
|