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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user