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
@@ -0,0 +1,132 @@
// Captures a key chord for rebinding.
//
// Shown in place of a shortcut's chord while it is being changed. It takes
// keyboard focus, waits for a non-modifier key, and reports the chord in the
// form hypr/keybinds.lua uses.
//
// Modifier-only presses are ignored rather than accepted, because every press
// of a chord passes through them: holding Super to type Super+K would otherwise
// be captured as "SUPER" the moment the modifier went down.
//
// Escape cancels. Not every Qt key has a keysym name Hyprland would accept, so
// an unmapped key is refused with a message rather than written as something
// that would silently fail to bind.
import QtQuick
import qs.config
FocusScope {
id: root
signal captured(string chord)
signal cancelled
property string message: ""
implicitWidth: 230
implicitHeight: 30
// Qt key codes to the keysym names Hyprland expects. Letters and digits
// fall out of the ASCII range; these are the rest that come up in practice.
readonly property var namedKeys: ({
0x01000000: "Escape",
0x01000001: "Tab",
0x01000004: "Return",
0x01000005: "Return",
0x01000003: "BackSpace",
0x01000006: "Insert",
0x01000007: "Delete",
0x01000010: "Home",
0x01000011: "End",
0x01000016: "Page_Up",
0x01000017: "Page_Down",
0x01000012: "left",
0x01000013: "up",
0x01000014: "right",
0x01000015: "down",
0x20: "space",
0x2c: "comma",
0x2e: "period",
0x2f: "slash",
0x3b: "semicolon",
0x27: "apostrophe",
0x5b: "bracketleft",
0x5d: "bracketright",
0x5c: "backslash",
0x60: "grave",
0x2d: "minus",
0x3d: "equal",
0x01000009: "Print"
})
function keysymFor(key: int): string {
if (key >= 0x41 && key <= 0x5a) // A-Z
return String.fromCharCode(key);
if (key >= 0x30 && key <= 0x39) // 0-9
return String.fromCharCode(key);
if (key >= 0x01000030 && key <= 0x0100003b) // F1-F12
return "F" + (key - 0x01000030 + 1);
return root.namedKeys[key] ?? "";
}
function isModifierOnly(key: int): bool {
return key === 0x01000020 || key === 0x01000021 || key === 0x01000022
|| key === 0x01000023 || key === 0x01000024;
}
Rectangle {
anchors.fill: parent
radius: 9
color: Theme.alpha(Theme.accent, 0.14)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
anchors.centerIn: parent
width: parent.width - 16
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
text: root.message !== "" ? root.message : "Press a shortcut… Esc to cancel"
color: root.message !== "" ? Theme.warn : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Keys.onPressed: event => {
event.accepted = true;
if (event.key === 0x01000000) { // Escape
root.cancelled();
return;
}
if (root.isModifierOnly(event.key))
return;
const keysym = root.keysymFor(event.key);
if (keysym === "") {
root.message = "That key cannot be used";
return;
}
const parts = [];
if (event.modifiers & Qt.MetaModifier) parts.push("SUPER");
if (event.modifiers & Qt.ControlModifier) parts.push("CTRL");
if (event.modifiers & Qt.AltModifier) parts.push("ALT");
if (event.modifiers & Qt.ShiftModifier) parts.push("SHIFT");
if (parts.length === 0 && keysym.length === 1) {
// A bare letter or digit would swallow ordinary typing.
root.message = "Add a modifier";
return;
}
parts.push(keysym);
root.captured(parts.join(" + "));
}
onActiveFocusChanged: {
if (!activeFocus)
root.cancelled();
}
}
@@ -17,6 +17,11 @@ import qs.services
SettingsPage {
id: root
// The chord of the bind currently being re-recorded, in the Lua form; empty
// when nothing is being captured. Held here rather than per row so that
// starting a new capture cancels any other.
property string capturingChord: ""
title: "Input & Shortcuts"
lede: "The Forge mental model, carried forward into native tiling."
@@ -72,19 +77,92 @@ SettingsPage {
model: groupCard.modelData.binds
TextRow {
SettingRow {
id: bindRow
required property var modelData
required property int index
label: modelData.description
value: modelData.chord
controlWidth: 230
divider: index < bindRows.count - 1
readonly property bool capturing: root.capturingChord === bindRow.modelData.luaChord
readonly property bool overridden: Keybinds.isOverridden(bindRow.modelData.luaChord)
label: bindRow.modelData.description
detail: bindRow.overridden
? "Moved from " + Keybinds.shippedChordFor(bindRow.modelData.luaChord)
: ""
controlWidth: 300
divider: bindRow.index < bindRows.count - 1
Item {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 300
height: 30
ShortcutCapture {
anchors.right: parent.right
width: 230
height: 30
visible: bindRow.capturing
focus: bindRow.capturing
onCaptured: chord => {
Keybinds.rebind(bindRow.modelData.luaChord, chord);
root.capturingChord = "";
}
onCancelled: root.capturingChord = ""
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !bindRow.capturing
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
text: bindRow.modelData.chord
color: bindRow.overridden ? Theme.accent : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Change"
enabled: !Keybinds.reloading && !bindRow.modelData.mouse
onClicked: root.capturingChord = bindRow.modelData.luaChord
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: bindRow.overridden
text: "Reset"
enabled: !Keybinds.reloading
onClicked: Keybinds.resetBind(bindRow.modelData.luaChord)
}
}
}
}
}
}
}
SettingsCard {
visible: Object.keys(Keybinds.overrides).length > 0
title: "Changed shortcuts"
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration."
ActionRow {
label: "Restore every shipped shortcut"
detail: Object.keys(Keybinds.overrides).length
+ (Object.keys(Keybinds.overrides).length === 1 ? " shortcut moved" : " shortcuts moved")
action: "Restore all"
divider: false
enabled: !Keybinds.reloading
onTriggered: Keybinds.resetAll()
}
}
SettingsCard {
visible: Keybinds.lastError !== ""
title: "Shortcuts unavailable"
@@ -34,3 +34,4 @@ WallpaperPicker 1.0 WallpaperPicker.qml
ApplicationsPage 1.0 ApplicationsPage.qml
DockPinsEditor 1.0 DockPinsEditor.qml
DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml