#!/usr/bin/env bash # Common XKB behavior should be discoverable without hiding the raw option # string from advanced users. This is source-only so it never remaps the live # keyboard while the desktop is in use. # # The presets became dropdowns and the raw strings moved into an "Advanced" # disclosure, which is the change this contract exists to bound. Collapsed is # fine -- a disclosure somebody can open is still the page telling them the # setting is there. Gone is not. So what is checked is that the raw # `keyboardOptions`, `keyboardVariant` and `keyboardLayout` fields are still on # THIS page, still editable, and still behind something that opens; not that # they are visible at rest. # # The reason for the distinction: every preset here writes one category of a # single comma-separated string. xkeyboard-config has hundreds of options and # the dropdowns offer eleven. Without the raw field, choosing anything else # means editing a preferences file by hand -- and worse, a preset would then # silently discard an option somebody had put there, with no way to see that it # had. The preservation rule below is only honest while the string is legible. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" page="$repo_dir/config/dot/quickshell/modules/settings/ShortcutsPage.qml" input="$repo_dir/config/dot/hypr/input.lua" schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" fail() { printf 'xkb presets contract: %s\n' "$1" >&2 exit 1 } [[ -r "$page" ]] || fail "cannot read $page" # ── The presets ────────────────────────────────────────────────────────────── # # The values are pinned; the labels are matched loosely, because what an # xkb option string says is a fact and what a row is called is a wording # decision. `caps:escape_shifted_capslock` moving would change somebody's # keyboard. "Compose key" becoming "Compose" would not. for needle in \ 'function currentXkbOption(prefix: string): string' \ 'function setXkbOption(prefix: string, option: string): void' \ 'value: "caps:escape_shifted_capslock"' \ 'value: "caps:ctrl_modifier"' \ 'value: "compose:ralt"' \ 'value: "grp:win_space_toggle"' \ 'SystemSettings.commitPreference("keyboardOptions"'; do rg -Fq "$needle" "$page" || fail "Shortcuts is missing $needle" done for label in 'Caps Lock' 'Compose' 'Layout switching'; do rg -qi "label: \"$label" "$page" || fail "Shortcuts has no \"$label\" row" done # Picking one category must replace only that category, preserving advanced # options from every other group. rg -Fq 'option.indexOf(prefix) !== 0' "$page" \ || fail 'preset updates do not preserve unrelated XKB options' # ── The raw strings, wherever they now sit on the page ─────────────────────── # # Matched as a block rather than a line, because these rows are nested inside a # disclosure now and a one-line `TextEntryRow { setting: "..." }` is no longer # how they are written. python3 - "$page" <<'PY' || fail 'the raw XKB fields are no longer editable on the Keyboard page -- a preset that silently drops an unrecognised option is the failure this prevents' import re import sys text = open(sys.argv[1], encoding="utf-8").read() missing = [] for setting in ("keyboardOptions", "keyboardVariant", "keyboardLayout"): pattern = rf"TextEntryRow\s*\{{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{{).)*?setting\s*:\s*\"{setting}\"" if not re.search(pattern, text, re.S): missing.append(setting) if missing: print("missing raw editors for: " + ", ".join(missing), file=sys.stderr) raise SystemExit(1 if missing else 0) PY # Collapsed is allowed; sealed shut is not. Something on the page has to open # the disclosure, and it has to be named so somebody knows what is behind it. rg -qi 'advanced' "$page" \ || fail 'the raw XKB fields are on the page but nothing names the section holding them' python3 - "$page" <<'PY' || fail 'the Advanced section has no control that opens it, so the raw fields are present but unreachable' import re import sys text = open(sys.argv[1], encoding="utf-8").read() # Whatever the state is called, a disclosure needs a handler that changes it. # `onClicked: root.showAdvanced = !root.showAdvanced`, `onTriggered: # root.advancedOpen = true`, an `expanded` id -- any of them satisfy this; a # hardcoded `visible: false` does not. handlers = re.findall(r"on(?:Clicked|Triggered|Toggled)\s*:.*", text) raise SystemExit(0 if any(re.search(r"advanced", line, re.I) for line in handlers) else 1) PY # A field that exists but can never be shown is the same as no field. python3 - "$page" <<'PY' || fail 'a raw XKB field is pinned invisible, which is indistinguishable from removing it' import re import sys text = open(sys.argv[1], encoding="utf-8").read() for setting in ("keyboardOptions", "keyboardVariant", "keyboardLayout"): pattern = rf"TextEntryRow\s*\{{((?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{{).)*?setting\s*:\s*\"{setting}\".*?)\n" block = re.search(pattern, text, re.S) if block and re.search(r"visible\s*:\s*false", block.group(1)): print(f"{setting} is declared visible: false", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # ── The values that survive a reload ───────────────────────────────────────── rg -Fq 'kb_variant = prefs.get("keyboardVariant", "")' "$input" \ || fail 'Hyprland does not replay the stored keyboard variant' rg -Fq 'key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock"' "$schema" \ || fail 'the shipped XKB default no longer matches the migrated GNOME behavior' rg -Fq 'kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock")' "$input" \ || fail 'Hyprland does not replay the stored keyboard options' printf 'xkb presets contract: PASS\n'