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:
@@ -492,6 +492,22 @@ Singleton {
|
||||
]
|
||||
},
|
||||
|
||||
// ── Keyboard shortcut overrides ─────────────────────────────────────
|
||||
// { "<bind description>": "<chord>" }. Only the chord is stored: the
|
||||
// action always comes from hypr/keybinds.lua, so an override can move a
|
||||
// shortcut but can never make one do something else. The Lua validates
|
||||
// each chord and falls back to the shipped one, so a hand-edited file
|
||||
// cannot cost you a keymap.
|
||||
//
|
||||
// Edited through the Input & Shortcuts page rather than as a row, hence
|
||||
// internal.
|
||||
{
|
||||
key: "keybindOverrides", type: "json", def: ({}), group: "input",
|
||||
internal: true,
|
||||
label: "Keyboard shortcut overrides",
|
||||
detail: "Shortcuts you have moved from their shipped chord"
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "lastPage", type: "string", def: "home", group: "internal",
|
||||
|
||||
@@ -2,12 +2,32 @@ import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "keybinds-test"
|
||||
|
||||
function rebind(current: string, next: string): bool {
|
||||
return Keybinds.rebind(current, next);
|
||||
}
|
||||
|
||||
function resetBind(current: string): void { Keybinds.resetBind(current); }
|
||||
function resetAll(): void { Keybinds.resetAll(); }
|
||||
|
||||
function chordFor(description: string): string {
|
||||
const found = Keybinds.binds.find(bind => bind.description === description);
|
||||
return found ? found.luaChord : "";
|
||||
}
|
||||
|
||||
function overrideState(): string {
|
||||
return JSON.stringify({
|
||||
overrides: Keybinds.overrides,
|
||||
count: Object.keys(Keybinds.overrides).length
|
||||
});
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
const grouped = Keybinds.grouped();
|
||||
let groupedCount = 0;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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