Give Input keycaps, a shortcut search, and the missing pointer basics
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user