Let shortcuts be rebound from Settings

Every bind in keybinds.lua now goes through a small wrapper that
substitutes the chord from a stored override. Only the chord is taken
from settings; 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. That is the property that makes reading them from a file the user
can edit safe, and it is why the alternative -- storing dispatchers --
was not considered.

Overrides are keyed by the shipped chord rather than the description.
Keying by description moved every bind that shared one: rebinding
SUPER+C also moved the XF86Calculator hardware key onto the same chord,
silently costing it. Chords are unique; descriptions are not.

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 outside to re-bind it; reload
re-runs the config, which re-reads the settings file.

The capture control ignores modifier-only presses, because every chord
passes through them and holding Super would otherwise be captured the
moment the modifier went down. It refuses a bare letter, which would
swallow ordinary typing, and refuses a key with no keysym name rather
than storing something that would fail to bind. Rebinding onto a chord
already in use is refused rather than shadowing the existing shortcut.

The refactor was verified by snapshotting all 113 binds before and
after: the keymap is byte-identical, and identical again after applying
an override and resetting it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-18 01:13:36 -04:00
parent 150f3cdb09
commit 634a9ebe07
8 changed files with 615 additions and 100 deletions
+138
View File
@@ -16,6 +16,7 @@ pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
@@ -65,6 +66,126 @@ Singleton {
}
}
// ── 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 {
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 "";
}
function rebind(currentChord: string, newChord: string): bool {
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): void {
const shipped = root.shippedChordFor(currentChord);
if (shipped === currentChord)
return;
const next = Object.assign({}, root.overrides);
delete next[shipped];
DesktopPreferences.set("keybindOverrides", next);
root.applyReload();
}
function resetAll(): void {
if (Object.keys(root.overrides).length === 0)
return;
DesktopPreferences.set("keybindOverrides", ({}));
root.applyReload();
}
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 {
@@ -89,6 +210,11 @@ Singleton {
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,
@@ -104,6 +230,18 @@ Singleton {
}
}
// "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());
}
parts.push(String(bind.key ?? ""));
return parts.join(" + ");
}
function formatChord(bind: var): string {
const parts = [];
for (const modifier of root.modifierBits) {