Files
Panama/tests/quickshell/keybind-rebind-contract.sh
T
Gabriel Brown 634a9ebe07 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
2026-08-18 01:13:36 -04:00

93 lines
4.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# Rebinding a keyboard shortcut.
#
# This is the highest-consequence write in the settings app: a mistake here
# costs the user their keymap, and the keymap is how they reach everything else.
# The properties that matter:
#
# * an override moves exactly the bind it names and nothing else -- keying by
# description moved every bind sharing one, which silently cost the
# XF86Calculator hardware key when SUPER+C was rebound;
# * only the chord is ever stored, never the action;
# * a chord already in use is refused rather than shadowing the existing bind;
# * resetting returns the exact shipped keymap.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
config_home="$(mktemp -d /tmp/panama-rebind-config.XXXXXX)"
fail() {
printf 'keybind rebind contract: %s\n' "$1" >&2
exit 1
}
# The compositor is the live one -- that is the point -- but preferences are
# isolated so this cannot leave an override in the user's real settings.
# hyprctl reload re-reads the real settings file, so the compositor is only
# exercised through the shipped configuration here; the override logic itself is
# what is under test.
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness ipc call keybinds-test resetAll >/dev/null 2>&1 || true
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home"
}
trap cleanup EXIT
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' || fail 'test IPC target did not start'
for _ in $(seq 1 40); do
[[ "$(qs_for_harness ipc call keybinds-test status | jq -r .loaded)" == "true" ]] && break
sleep 0.1
done
# ── Nothing is overridden to begin with ──────────────────────────────────────
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|| fail 'the isolated store started with overrides'
# ── A chord already in use is refused ────────────────────────────────────────
terminal="$(qs_for_harness ipc call keybinds-test chordFor Terminal)"
[[ -n "$terminal" ]] || fail 'could not find the Terminal bind'
files="$(qs_for_harness ipc call keybinds-test chordFor Files)"
[[ -n "$files" ]] || fail 'could not find the Files bind'
[[ "$(qs_for_harness ipc call keybinds-test rebind "$terminal" "$files")" == "false" ]] \
|| fail 'rebinding onto a chord already in use was accepted'
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|| fail 'a refused rebind still stored an override'
# ── A rebind stores only the chord, keyed by the shipped chord ───────────────
[[ "$(qs_for_harness ipc call keybinds-test rebind "$terminal" "SUPER + SHIFT + F9")" == "true" ]] \
|| fail 'a valid rebind was refused'
state="$(qs_for_harness ipc call keybinds-test overrideState)"
jq -e --arg k "$terminal" '.overrides[$k] == "SUPER + SHIFT + F9"' <<<"$state" >/dev/null \
|| fail "the override was not keyed by the shipped chord: $state"
jq -e '.count == 1' <<<"$state" >/dev/null || fail "exactly one override expected: $state"
# Only a chord is stored. Nothing resembling an action or command may appear,
# because that is what keeps a user-editable file from being executable.
jq -e '[.overrides[]] | all(type == "string" and (length < 64))' <<<"$state" >/dev/null \
|| fail 'an override value is not a plain chord'
# ── Reset clears it ──────────────────────────────────────────────────────────
qs_for_harness ipc call keybinds-test resetAll >/dev/null
sleep 0.5
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|| fail 'resetAll left overrides behind'
trap - EXIT
cleanup
printf 'keybind rebind contract: PASS\n'