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
@@ -459,3 +459,140 @@ needs nothing.
already true of the existing `exercise` fixture; the new fixtures add four
more chances for it. Keep the harness free of anything that turns the volume
up.
## Phase 8 (Input) — append below
Spec: `2026-08-24-input-redesign.md`. Keyboard, Mouse & Touchpad and Dictation
were rebuilt around keycaps, dropdowns and a searchable shortcuts browser, and
seven new compositor-backed preference keys landed with them.
**Nothing here was run against a live harness.** Three agents edited the tree
concurrently. What *was* verified is listed per contract below: `bash -n` on
every changed contract, the two source-only contracts run end to end, and the
three compositor-shape contracts replayed offline — `hyprctl descriptions` and
`hyprctl getoption` are read-only queries, so their answers were captured once
and the contract logic replayed against that snapshot with a stub on `PATH`,
never against the running compositor mid-edit.
### New contracts (0)
None. The redesign added rows, components and schema keys to surfaces that
already had contracts, so the README count line stays at **169** and
`setup/readme-contract` needs nothing. (`find` counts 169; the README says 169.)
### The seven new schema keys, per key
Each had to satisfy three contracts at once. Replayed against the landed
`PreferenceSchema.qml`, `hypr/input.lua` and a captured `hyprctl` snapshot:
| Key | Hyprland option | enum-hypr-map | schema-hypr-shape | hypr-prefs |
|---|---|---|---|---|
| `focusOnClose` | `input:focus_on_close` | **PASS** — enum, all three published values offered | **PASS**`readAs: "int"`, answers `int` | **PASS**`prefs.getInt("focusOnClose", 0)`, def 0 |
| `scrollMethod` | `input:scroll_method` | **PASS** — string enum, every offered word in the published list | **PASS**`readAs: "str"`, answers `str` | **PASS**`prefs.get("scrollMethod", "")`, def `""` |
| `scrollButton` | `input:scroll_button` | n/a — `type: "int"`, not an enum | **PASS**`readAs: "int"`, answers `int` | **PASS**`prefs.get("scrollButton", 0)`, def 0 |
| `cursorHideWhileTyping` | `cursor:hide_on_key_press` | n/a — bool | **PASS**`readAs: "bool"`, answers `bool` | **PASS** — def false |
| `cursorWarpOnWorkspaceChange` | `cursor:warp_on_change_workspace` | n/a — bool over an int option, so the enum rule does not reach it | **PASS**`readAs: "int"`, answers `int` | **PASS**`prefs.getInt(..., 0)` vs schema `false`; the contract's own true/false→1/0 normalization is what makes those agree |
| `touchpadClickfinger` | `input:touchpad:clickfinger_behavior` | n/a — bool | **PASS**`readAs: "bool"` | **PASS** — def false |
| `touchpadTapAndDrag` | `input:touchpad:tap-and-drag` | n/a — bool | **PASS**`readAs: "bool"` | **PASS** — def true |
Two of those are decisions, not just passes, and both are recorded in the
schema itself:
- `focusOnClose` was specced as a two-way choice. The compositor publishes
three (`{"mru":2},{"cursor":1},{"next":0}`) and **0 is what this desktop runs
today**, so a two-option dropdown would have hidden the shipped default from
its own control. enum-hypr-map-contract fails an enum that drops a published
value, and would have caught it — verified by deleting value 0 from a copy of
the schema and watching it fail with exactly that message.
- `cursorWarpOnWorkspaceChange` is a switch over an option with three states.
`force` (2) is deliberately unreachable from Settings. enum-hypr-map governs
enums only, so nothing fails — which is the point of writing it down here.
### Updated contracts (6)
| Contract | What it now pins | Verified |
|---|---|---|
| `quickshell/enum-hypr-map-contract` | **String-valued enums are now checked at all.** The parser only ever collected numeric `value:`s, so `accelProfile`, `masterOrientation`, `masterNewStatus` and `windowLayout` were silently skipped and `scrollMethod` would have been too. String options carry no `map`; Hyprland states their accepted words inside the description (`[2fg/edge/on_button_down/no_scroll]`), so those are parsed and checked **one way only**: an offered value the compositor does not name fails; a named value Settings does not offer does not, because that is a product decision (`accel_profile`'s `custom` needs a `scroll_points` curve and is a stated non-goal). Empty string is always allowed — it is how a schema entry says "leave the compositor's default", which is what `[[EMPTY]]` reads back as. Options with no bracket list print a line saying so and are skipped rather than failing. The numeric rules are untouched. | **Replayed offline** against a captured `hyprctl descriptions` (353 options) and the landed schema: PASS, 11 mapped enums (was 9). Both new failure directions exercised on a scratch copy — dropping `focus_on_close`'s value 0 fails, offering `"two_finger"` for `scroll_method` fails. |
| `quickshell/xkb-presets-contract` | Rewritten for the Advanced disclosure. The raw `keyboardOptions`, `keyboardVariant` **and `keyboardLayout`** fields must still be editable on the Keyboard page — matched as blocks, not one-liners, so nesting them inside an expander is fine — and **collapsed-but-present passes while absent fails**: the section must be named ("Advanced") and some `onClicked`/`onTriggered`/`onToggled` handler must actually open it, and no raw field may be pinned `visible: false`. XKB *values* stay pinned exactly (`caps:escape_shifted_capslock`, `caps:ctrl_modifier`, `compose:ralt`, `grp:win_space_toggle`) because moving one changes somebody's keyboard; row *labels* are now matched loosely and case-insensitively, because "Compose key" → "Compose" is a wording decision. The category-preservation rule, both helper signatures, and the three `input.lua`/schema default needles are unchanged. | **Run end to end** against the landed tree — it is source-only and touches neither compositor nor shell. PASS. Nested-block matching and the "no handler ⇒ fail" direction both exercised. |
| `quickshell/keybinds-contract` | A static presentation half ahead of the existing live count check, which a wall of 130 rows and a searchable browser pass identically. Page and `ShortcutRow.qml` are read as one source, so moving a control between them is not a failure: `Keybinds.grouped()` is what supplies the group order, `KeycapChord` is what draws chords, the filter exists and is case-insensitive and matches on `description`, a filtered list says how many of how many it is `showing`, the header count comes from `Keybinds.binds.length`, and the note explaining why there is no GNOME keyboard handoff survives. Count-match, description-completeness and chord-rendering rules are untouched. | **Static half replayed** against the landed `ShortcutsPage.qml` + `ShortcutRow.qml`: all needles hit. The compositor half is **deferred** — it boots a Quickshell harness. |
| `quickshell/keybind-rebind-contract` | UI needles added to the static half (the one that already runs under `PANAMA_KEYBINDS_STATIC_ONLY=1`). Page + row read as one source: `ShortcutCapture` is still what reads key presses (a page that grew its own handler would capture SUPER as a bind of its own), `boundTo` is called **before** `rebind` on the source line order, Change/Reset/`resetBind`/`resetAll`/`isOverridden` all still exist, binds are identified by `luaChord`, and no `rebind`/`resetBind` call is keyed by `description` — the regression that once moved every bind sharing one and cost the XF86Calculator key. The restore-all row and its live differs-count are pinned on the page. Engine needles and the whole live half are untouched. | **`PANAMA_KEYBINDS_STATIC_ONLY=1` run against the landed tree: PASS (static).** All 20 needles individually replayed. Live half **deferred**. |
| `quickshell/settings-pages-contract` | `Shortcuts` and `Mouse` added to the root-type/no-copied-Flickable sweep (neither page was ever in it), `mouse` added to the runtime routing sweep, and a hand-written check that all seven new keys render on `MousePage.qml` — by `setting: "key"` *or* by `commitPreference("key"`. That second spelling is why it is hand-written: dropdowns now render through `OptionPickerRow`, which takes label and options from `PreferenceSchema.spec()` and commits by name, and **has no `setting:` property at all**. Existing Home/Bar/Notifications/ScreenIntelligence pins unchanged. | **Static half run: PASS.** Routing sweep **deferred** — it starts an isolated Quickshell beside the live one. |
| `setup/dictation-contract` | **Every "where text lands" pin is unchanged and none of them conflicts with the on-page test.** They live on `panama-dictate` and `keybinds.lua``is_speech` rejecting `[BLANK_AUDIO]`, the guard actually being called before typing, the newline collapse, `wtype` tried before `wl-copy`, one press bind and one release bind — and nothing in them constrains which window has focus. The Try-it field sends the same `start`/`stop` the hotkey sends and merely holds keyboard focus while `wtype` types. What is new is two needles for the risk the test flow *did* introduce: the page must not spell out `scripts/panama-dictate` (the service publishes that path once, and a second copy would go stale silently, since the page's status readout comes from the service and would still be right), and a page that runs a `Process` must go through `Dictation.helper`. | **Run end to end** — it is greps plus a `python3` import of the helper, no compositor and no shell. PASS, including both new needles against the landed `DictationPage.qml`. |
### Verified against the new tree, no edit needed
- `quickshell/schema-hypr-shape-contract` — derives everything from
`option: "...", readAs: "..."` pairs in the schema, so the seven new keys
entered it the moment they landed. All seven extract cleanly and all seven
`readAs` values match what `hyprctl -j getoption` answers with. Two were easy
to get wrong and are worth naming: `cursor:warp_on_change_workspace` answers
`int` despite being a switch in the UI, and `input:scroll_method` answers
`str` despite the neighbouring `scroll_button` answering `int`.
- `tests/hypr/hypr-prefs-contract` — pure static, and **run**: ok, 77
compositor-owned keys read at config time, up from 70. All seven new keys
have a `prefs.get()`/`prefs.getInt()` in `config/dot/hypr/input.lua` with a
fallback equal to the schema default.
- `quickshell/gnome-handoff-contract` — needle-free by construction (it derives
both sides). **Run**: ok, 14 handoffs checked against 39 pages. The Keyboard
page still has no GNOME handoff and still explains why.
- `quickshell/schema-hypr-shape-contract`, `tests/hypr/hypr-prefs-contract` and
`quickshell/gnome-handoff-contract` are the three above. `setup/readme-contract`
is a fourth: no contract file was added or removed, `find` still counts 169,
and the README still claims 169.
### Docs updated in the same wave
- `services/SettingsSearch.qml` — three hand-written entries: **Rebind a
shortcut** (→ `shortcuts`), **Pointer test area** and **Connected input
devices** (→ `mouse`). "Key repeat" and "Scroll method" arrive automatically
from the schema, as the spec expected. Checked against
`settings-search-contract`'s fixed query list: of its 27 pinned queries only
`pointer` matches any new entry, and "Pointer test area" sorts *after*
"Pointer focus", "Pointer size" and "Pointer speed" in the same prefix rank,
so no pinned top result moves. Both new pages are leaves in
`SettingsRoutes`, so the "routes to a page anyone can land on" sweep holds.
- No settings docs or launcher commands were regenerated here — that is the
orchestrator's step after the schema settled.
### Still open before the run
- **`settings-ownership-contract` and `search-routing-contract` are now blind
to dropdown rows.** Both scan for `setting: "…"` inside a fixed list of row
types; `OptionPickerRow` is in neither list and carries no `setting:`
property. `accelProfile`, `followMouse`, `focusOnClose` and `scrollMethod`
are all invisible to them on the rebuilt `MousePage.qml`. Nothing fails
today — none of those keys is a duplicate — but a duplicate introduced
through a dropdown would not be caught. `settings-pages-contract` now pins
the seven new keys directly as a stopgap; the real fix is teaching both
scans the `PreferenceSchema.spec()` / `commitPreference()` spelling. Owner:
whoever holds those two contracts next.
- **`xkb-presets-contract` now requires a raw `keyboardLayout` field**, on the
reading that the layout dropdown's "Custom…" has to reveal somewhere the
code can actually be typed. It passes against the landed page. If the layout
editor is ever folded into the dropdown itself, that needle is the one to
revisit — the intent is "the raw code stays typeable", not "it is a
TextEntryRow".
- **`keybinds-contract` pins the literal word `showing`** in the filtered-count
line, because the spec names that wording ("showing N of M"). It is the one
prose needle in the new static half; everything else keys on structure.
- **Dictation's Try-it field and the clipboard fallback** — handled, but worth
knowing. `panama-dictate` falls back to `wl-copy` when `wtype` is missing, by
design, and on the Try-it row that means the words land on the clipboard
rather than in the field the page just focused. The page says so: a "Typing —
Missing" row appears when `Dictation.typingAvailable` is false. Nothing to
fix; worth a look during the run if a machine without `wtype` is around, since
that branch has never been seen.
- Run order for this phase: the two source-only contracts first
(`xkb-presets-contract`, `gnome-handoff-contract`), then the static halves
(`hypr-prefs-contract`, `PANAMA_KEYBINDS_STATIC_ONLY=1 keybind-rebind-contract`,
`PANAMA_SETTINGS_STATIC_ONLY=1 settings-pages-contract`), then the two
compositor-query contracts (`enum-hypr-map-contract`,
`schema-hypr-shape-contract` — read-only, but they want the real compositor),
then the harness contracts (`keybinds-contract`, `keybind-rebind-contract`
in full, `settings-search-contract`), and `settings-pages-contract` last, as
before: it starts an isolated Quickshell beside the live one and its own
cleanup is what protects the running session.
- `keybind-rebind-contract`'s live half rebinds Terminal to `SUPER + SHIFT +
F9` against the **real compositor** with an isolated `XDG_CONFIG_HOME`. That
was true before this phase and is unchanged, but it is the one contract in
this wave that writes to the running keymap, so it wants a quiet moment.