Give Input keycaps, a shortcut search, and the missing pointer basics

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 15:21:01 -04:00
parent b8f88a91f3
commit aba2d16ffa
25 changed files with 2346 additions and 191 deletions
+42 -6
View File
@@ -17,6 +17,18 @@
#
# So this checks the schema's enum values against that map, and against the
# min/max range for mapped options that have no named map.
#
# String-valued options are checked too, less strictly and for a reason. They
# carry no `map` at all -- Hyprland states their accepted values inside the
# description, as `input:scroll_method` does with "[2fg/edge/on_button_down/
# no_scroll]". That list is prose, so it is trustworthy in one direction only:
# a value the compositor does not name is a value it will reject or silently
# ignore, which this fails on; a value the compositor names but Settings does
# not offer may simply be one Panama has decided against (accel_profile's
# `custom` needs a scroll_points curve to mean anything, and is a stated
# non-goal), so that direction is not an error here. The empty string is always
# allowed: it is how a schema entry says "leave it at the compositor's default",
# which is what `[[EMPTY]]` reads back as.
set -uo pipefail
@@ -33,7 +45,8 @@ descriptions="$(hyprctl descriptions 2>/dev/null)" || fail 'could not read hyprc
jq -e 'type == "array" and length > 0' >/dev/null <<<"$descriptions" \
|| fail 'hyprctl descriptions did not return a list'
# Pull every enum entry that carries a hypr option, as: key<TAB>option<TAB>values
# Pull every enum entry that carries a hypr option, as:
# key<TAB>option<TAB>kind<TAB>values (kind is "int" or "str")
entries="$(python3 - "$schema" <<'PY'
import re, sys
@@ -46,22 +59,45 @@ for block in re.findall(r'\{\s*\n?\s*key:\s*"([^"]+)"(.*?)\n \}', text, r
option = re.search(r'option:\s*"([^"]+)"', body)
if not option:
continue
values = re.findall(r'value:\s*(-?\d+)', body)
if not values:
continue
print(f"{name}\t{option.group(1)}\t{','.join(values)}")
numbers = re.findall(r'value:\s*(-?\d+)\s*[,}]', body)
strings = re.findall(r'value:\s*"([^"]*)"\s*[,}]', body)
if numbers:
print(f"{name}\t{option.group(1)}\tint\t{','.join(numbers)}")
elif strings:
print(f"{name}\t{option.group(1)}\tstr\t{','.join(strings)}")
PY
)"
[[ -n "$entries" ]] || fail 'found no compositor-backed enums in the schema -- this contract is not reading it correctly'
checked=0
while IFS=$'\t' read -r key option values; do
while IFS=$'\t' read -r key option kind values; do
[[ -n "$key" ]] || continue
entry="$(jq -c --arg name "$option" '.[] | select(.name == $name)' <<<"$descriptions")"
[[ -n "$entry" ]] || fail "$key maps to \"$option\", which the compositor does not publish"
# ── String-valued options: no map, an accepted-value list in the prose ────
if [[ "$kind" == "str" ]]; then
published="$(jq -r '.description // ""' <<<"$entry" \
| grep -oE '\[[a-z0-9_/-]+\]$' | tr -d '[]' | tr '/' '\n')"
if [[ -z "$published" ]]; then
printf 'enum hypr map contract: %s maps to "%s", which publishes no value list to check against\n' \
"$key" "$option" >&2
checked=$((checked + 1))
continue
fi
IFS=',' read -ra wanted <<<"$values"
for value in "${wanted[@]}"; do
# "" is the schema saying "leave the compositor's own default".
[[ -n "$value" ]] || continue
grep -qx "$value" <<<"$published" \
|| fail "$key offers \"$value\" for $option, which the compositor does not accept (it names: $(tr '\n' ' ' <<<"$published"))"
done
checked=$((checked + 1))
continue
fi
map_values="$(jq -r 'if .map then (.map | map(to_entries[].value) | join(",")) else "" end' <<<"$entry")"
IFS=',' read -ra wanted <<<"$values"
+58
View File
@@ -18,6 +18,9 @@ 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
@@ -29,6 +32,61 @@ rg -Fq 'function overrideOccupantFor(chord: string, exceptShipped: string): stri
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
+44
View File
@@ -16,12 +16,56 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
page="$settings_dir/ShortcutsPage.qml"
row="$settings_dir/ShortcutRow.qml"
fail() {
printf 'keybinds contract: %s\n' "$1" >&2
exit 1
}
# ── How the hundred and thirty are presented ─────────────────────────────────
#
# The count check below proves the page HAS every bind. These prove it is still
# something a person can find one in. A wall of a hundred and thirty rows and a
# searchable browser pass the count check identically.
[[ -r "$page" ]] || fail "cannot read $page"
[[ -r "$row" ]] || fail "cannot read $row -- the shortcut row component is gone"
browser="$(cat "$page" "$row")"
grep -Fq 'Keybinds.grouped()' <<<"$browser" \
|| fail 'the page no longer renders the service grouping, so the group order is a second opinion'
# Chords are drawn as keys. The chord string itself stays the service's -- the
# component is presentation over what Keybinds already produced, never a second
# spelling of a binding.
grep -Fq 'KeycapChord' <<<"$browser" \
|| fail 'chords are no longer drawn as keycaps'
# A filter, matching what a shortcut does rather than what it is bound to:
# nobody looking for the screenshot key knows it is Super+Shift+S, which is the
# entire reason for searching.
grep -Fq 'toLowerCase()' <<<"$browser" \
|| fail 'the shortcut filter is gone, or is case-sensitive'
grep -qE 'description[^\n]*toLowerCase|toLowerCase[^\n]*description' <<<"$browser" \
|| fail 'the filter does not match a shortcut by its description'
# Filtering must say what it hid. A list that silently shrinks reads as a
# shortcut having been lost.
grep -qi 'showing' <<<"$browser" \
|| fail 'a filtered list never says how many of how many it is showing'
# Counts come from the service, so adding a bind moves the number on the page.
grep -Fq 'Keybinds.binds.length' <<<"$browser" \
|| fail 'the header count is not read from the live keymap'
# The reason there is no GNOME keyboard button here, kept where the next person
# to wonder about it will look. gnome-handoff-contract cannot state a reason.
rg -Fq 'writes org.gnome.desktop input-source' "$page" \
|| fail 'the note explaining why there is no GNOME keyboard handoff is gone'
qs_for_harness() {
qs -p "$harness" "$@"
}
+20 -2
View File
@@ -9,7 +9,7 @@ fail() {
exit 1
}
pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Dictation Notifications Focus ScreenIntelligence Health About)
pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About)
for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
@@ -79,6 +79,24 @@ block = re.search(
raise SystemExit(0 if block and re.search(r'zeroLabel\s*:\s*"Never"', block.group(0)) else 1)
PY
# The pointer and touchpad keys added with the Input redesign, each on the page
# its schema group routes to.
#
# Checked by hand rather than through require_row, because half of them render
# through OptionPickerRow, which takes its label and options from
# `PreferenceSchema.spec()` and commits by name -- it has no `setting:`
# property at all. That is a deliberate pattern for dropdowns, but it means the
# scans that look for `setting:` rows (settings-ownership-contract's
# duplicate-key sweep, search-routing-contract's routing sweep) cannot see
# these rows, so a key that stopped rendering would leave no other trace.
mouse_page="$repo_dir/config/dot/quickshell/modules/settings/MousePage.qml"
for setting in focusOnClose scrollMethod scrollButton cursorHideWhileTyping \
cursorWarpOnWorkspaceChange touchpadClickfinger touchpadTapAndDrag; do
rg -Fq "setting: \"$setting\"" "$mouse_page" \
|| rg -Fq "commitPreference(\"$setting\"" "$mouse_page" \
|| fail "MousePage.qml renders no control for $setting"
done
intelligence_page="$repo_dir/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml"
# Free text, not a choice. Three preset folders could not include the one the
# rest of somebody's software already writes to, which is the only folder that
@@ -311,7 +329,7 @@ shell_pid="$harness_pid"
# four different categories, and the page the tab strip was introduced for.
# Routing to a tab must land on that tab, not on whatever its category opens
# first, which is the failure the SettingsRoutes resolution could introduce.
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts services manual about)
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts mouse services manual about)
for page in "${pages[@]}"; do
qs_for_test ipc call settings page "$page" >/dev/null
for _ in $(seq 1 20); do
+85 -5
View File
@@ -3,6 +3,21 @@
# 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
@@ -16,26 +31,91 @@ fail() {
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' \
'label: "Caps Lock"' \
'value: "caps:escape_shifted_capslock"' \
'value: "caps:ctrl_modifier"' \
'label: "Compose key"' \
'value: "compose:ralt"' \
'label: "Layout switching"' \
'value: "grp:win_space_toggle"' \
'SystemSettings.commitPreference("keyboardOptions"' \
'TextEntryRow { setting: "keyboardOptions"'; do
'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" \