diff --git a/config/dot/quickshell/keybinds-harness.qml b/config/dot/quickshell/keybinds-harness.qml index 87e2ebe..e1902ae 100644 --- a/config/dot/quickshell/keybinds-harness.qml +++ b/config/dot/quickshell/keybinds-harness.qml @@ -13,9 +13,16 @@ ShellRoot { return Keybinds.rebind(current, next); } - function resetBind(current: string): void { Keybinds.resetBind(current); } + function resetBind(current: string): bool { return Keybinds.resetBind(current); } function resetAll(): void { Keybinds.resetAll(); } + function seedResetCollision(shipped: string, current: string, occupantShipped: string): void { + const overrides = {}; + overrides[shipped] = current; + overrides[occupantShipped] = shipped; + DesktopPreferences.set("keybindOverrides", overrides); + } + function chordFor(description: string): string { const found = Keybinds.binds.find(bind => bind.description === description); return found ? found.luaChord : ""; @@ -24,7 +31,8 @@ ShellRoot { function overrideState(): string { return JSON.stringify({ overrides: Keybinds.overrides, - count: Object.keys(Keybinds.overrides).length + count: Object.keys(Keybinds.overrides).length, + lastError: Keybinds.lastError }); } diff --git a/config/dot/quickshell/services/Keybinds.qml b/config/dot/quickshell/services/Keybinds.qml index 59a7c85..89ccfeb 100644 --- a/config/dot/quickshell/services/Keybinds.qml +++ b/config/dot/quickshell/services/Keybinds.qml @@ -108,6 +108,17 @@ Singleton { return ""; } + // A moved shortcut vacates its shipped chord, so another override may use + // it legitimately. Refuse to reset the first shortcut until that occupant + // moves away; otherwise Hyprland would receive two binds on one chord. + function overrideOccupantFor(chord: string, exceptShipped: string): string { + for (const shipped in root.overrides) { + if (shipped !== exceptShipped && root.overrides[shipped] === chord) + return shipped; + } + return ""; + } + function rebind(currentChord: string, newChord: string): bool { if (newChord === "" || newChord === currentChord) return false; @@ -133,14 +144,25 @@ Singleton { return true; } - function resetBind(currentChord: string): void { + function resetBind(currentChord: string): bool { const shipped = root.shippedChordFor(currentChord); if (shipped === currentChord) - return; + return true; + + const occupant = root.overrideOccupantFor(shipped, shipped); + if (occupant !== "") { + root.lastError = `${shipped} is used by another rebound shortcut. Reset that shortcut first.`; + return false; + } + const next = Object.assign({}, root.overrides); delete next[shipped]; - DesktopPreferences.set("keybindOverrides", next); + if (!DesktopPreferences.set("keybindOverrides", next)) { + root.lastError = "That shortcut could not be reset."; + return false; + } root.applyReload(); + return true; } function resetAll(): void { diff --git a/tests/quickshell/keybind-rebind-contract.sh b/tests/quickshell/keybind-rebind-contract.sh index d68aad4..c7eb7e0 100755 --- a/tests/quickshell/keybind-rebind-contract.sh +++ b/tests/quickshell/keybind-rebind-contract.sh @@ -17,13 +17,25 @@ 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)" +service="$repo_dir/config/dot/quickshell/services/Keybinds.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' + +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 @@ -87,6 +99,19 @@ 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'