176 lines
8.3 KiB
Bash
Executable File
176 lines
8.3 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"
|
|
service="$repo_dir/config/dot/quickshell/services/Keybinds.qml"
|
|
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
|
page="$settings_dir/ShortcutsPage.qml"
|
|
row="$settings_dir/ShortcutRow.qml"
|
|
|
|
fail() {
|
|
printf 'keybind rebind contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
rg -Fq 'function overrideOccupantFor(chord: string, exceptShipped: string): string' "$service" \
|
|
|| fail 'resetBind has no override collision guard'
|
|
rg -Fq 'root.overrideOccupantFor(shipped, shipped)' "$service" \
|
|
|| fail 'resetBind does not check whether another override occupies its shipped chord'
|
|
|
|
# ── The page that drives all of the above ────────────────────────────────────
|
|
#
|
|
# The engine is exercised for real below, but the engine is only reachable
|
|
# through one screen, and the screen has been rebuilt around a per-row
|
|
# component. These pin the parts of that screen that carry consequence: every
|
|
# one of them is a way to lose the rebinding flow without any test noticing,
|
|
# because the service would still be perfectly correct.
|
|
#
|
|
# The row and the page are read as one source, so moving a control between them
|
|
# is not a failure -- removing it is.
|
|
|
|
[[ -r "$page" ]] || fail "cannot read $page"
|
|
[[ -r "$row" ]] || fail "cannot read $row -- the shortcut row component is gone"
|
|
browser="$(cat "$page" "$row")"
|
|
|
|
# The capture field is shared with nothing else and is the only thing in the
|
|
# tree that reads a chord without acting on it. A page that grew its own key
|
|
# handler instead would capture SUPER as a bind of its own.
|
|
grep -Fq 'ShortcutCapture' <<<"$browser" \
|
|
|| fail 'the shortcuts browser no longer uses ShortcutCapture, so something else is reading key presses'
|
|
|
|
for needle in \
|
|
'Keybinds.boundTo(' \
|
|
'Keybinds.rebind(' \
|
|
'Keybinds.resetBind(' \
|
|
'Keybinds.resetAll()' \
|
|
'Keybinds.isOverridden(' \
|
|
'text: "Change"' \
|
|
'text: "Reset"'; do
|
|
grep -Fq "$needle" <<<"$browser" \
|
|
|| fail "the shortcuts browser is missing $needle"
|
|
done
|
|
|
|
# boundTo before rebind. Without the check the write still succeeds and two
|
|
# actions end up on one chord, with whichever Hyprland reads last winning.
|
|
conflict_line="$(grep -n 'Keybinds.boundTo(' <<<"$browser" | head -1 | cut -d: -f1)"
|
|
write_line="$(grep -n 'Keybinds.rebind(' <<<"$browser" | head -1 | cut -d: -f1)"
|
|
[[ -n "$conflict_line" && -n "$write_line" && "$conflict_line" -lt "$write_line" ]] \
|
|
|| fail 'the chord in use is not checked before the rebind is written'
|
|
|
|
# Keyed by the shipped Lua chord, never by the description. Keying by
|
|
# description is what once moved every bind that shared one, silently costing
|
|
# the XF86Calculator hardware key when SUPER+C was rebound.
|
|
grep -Fq 'luaChord' <<<"$browser" \
|
|
|| fail 'the browser does not identify binds by their shipped Lua chord'
|
|
if grep -qE 'Keybinds\.(rebind|resetBind)\([^)]*description' <<<"$browser"; then
|
|
fail 'a rebind or reset is keyed by description, which moves every bind that shares one'
|
|
fi
|
|
|
|
# Restoring everything stays reachable, and says how much it would undo.
|
|
rg -Fq 'Restore every shipped shortcut' "$page" \
|
|
|| fail 'the restore-all row is gone'
|
|
rg -Fq 'Object.keys(Keybinds.overrides).length' "$page" \
|
|
|| fail 'the restore-all row no longer counts what it would put back'
|
|
|
|
if [[ "${PANAMA_KEYBINDS_STATIC_ONLY:-0}" == "1" ]]; then
|
|
printf 'keybind rebind contract: PASS (static)\n'
|
|
exit 0
|
|
fi
|
|
|
|
config_home="$(mktemp -d /tmp/panama-rebind-config.XXXXXX)"
|
|
|
|
# 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'
|
|
|
|
# Terminal moved away from its shipped chord, then Files moved into it.
|
|
# Resetting Terminal must refuse instead of producing two binds on one chord.
|
|
qs_for_harness ipc call keybinds-test seedResetCollision \
|
|
"$terminal" "SUPER + SHIFT + F9" "$files" >/dev/null
|
|
sleep 0.2
|
|
[[ "$(qs_for_harness ipc call keybinds-test resetBind "SUPER + SHIFT + F9")" == "false" ]] \
|
|
|| fail 'resetBind reclaimed a shipped chord occupied by another override'
|
|
collision_state="$(qs_for_harness ipc call keybinds-test overrideState)"
|
|
jq -e --arg terminal "$terminal" --arg files "$files" \
|
|
'.count == 2 and .overrides[$terminal] == "SUPER + SHIFT + F9" and .overrides[$files] == $terminal and (.lastError | length > 0)' \
|
|
<<<"$collision_state" >/dev/null \
|
|
|| fail "a refused reset changed overrides or gave no explanation: $collision_state"
|
|
|
|
trap - EXIT
|
|
cleanup
|
|
printf 'keybind rebind contract: PASS\n'
|