Give Input keycaps, a shortcut search, and the missing pointer basics
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
+8
-1
@@ -4,7 +4,7 @@
|
||||
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
||||
after changing the schema; a contract fails when this copy is stale.
|
||||
|
||||
165 settings across 35 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
|
||||
172 settings across 35 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
|
||||
|
||||
## accessibility
|
||||
|
||||
@@ -283,6 +283,11 @@ Found on **Input › Mouse & Touchpad**.
|
||||
| **Scroll speed**<br>`scrollFactor` `input:scroll_factor` | 1.0 | Multiplies how far one notch of the wheel scrolls. Range 0.1–4.0. |
|
||||
| **Left-handed**<br>`leftHanded` `input:left_handed` | false | Swap the primary and secondary buttons |
|
||||
| **Middle-click paste**<br>`middleClickPaste` `misc:middle_click_paste` | true | Paste the primary selection in GTK and native Wayland applications |
|
||||
| **Focus after closing**<br>`focusOnClose` `input:focus_on_close` | 0 | Which window takes keyboard focus when the focused one goes away Choices: Next in the stack, Under the pointer, Most recently used. |
|
||||
| **Scroll method**<br>`scrollMethod` `input:scroll_method` | — | How a pointing device turns movement into scrolling Choices: Two fingers, Along the edge of the touchpad, While a button is held, Never scroll. |
|
||||
| **Scroll button**<br>`scrollButton` `input:scroll_button` | 0 | Which button is held to scroll, as an evdev code; 0 lets the device choose. Range 0–300. |
|
||||
| **Hide pointer while typing**<br>`cursorHideWhileTyping` `cursor:hide_on_key_press` | false | The pointer vanishes on the next keystroke and returns when you move it |
|
||||
| **Jump pointer to the focused display**<br>`cursorWarpOnWorkspaceChange` `cursor:warp_on_change_workspace` | false | Moves the pointer to the last focused window after switching workspace |
|
||||
|
||||
## search
|
||||
|
||||
@@ -331,6 +336,8 @@ Found on **Input › Mouse & Touchpad**.
|
||||
| **Scroll speed**<br>`touchpadScrollFactor` `input:touchpad:scroll_factor` | 1.0 | Multiplies how far a two-finger scroll travels. Range 0.1–4.0. |
|
||||
| **Drag lock**<br>`touchpadDragLock` `input:touchpad:drag_lock` | 0 | Keeps a tap-and-drag active when you lift a finger mid-drag Choices: Off, On, On, until you tap again. |
|
||||
| **Middle-click by pressing both buttons**<br>`touchpadMiddleButtonEmulation` `input:touchpad:middle_button_emulation` | false | Pressing left and right together acts as a middle click |
|
||||
| **Two-finger right-click**<br>`touchpadClickfinger` `input:touchpad:clickfinger_behavior` | false | One, two, or three fingers pressing down give left, right, and middle click, instead of clicking by which part of the pad you press |
|
||||
| **Tap and drag**<br>`touchpadTapAndDrag` `input:touchpad:tap-and-drag` | true | A tap followed straight away by a tap-and-hold starts a drag, with nothing pressed down |
|
||||
| **Swipe distance**<br>`swipeDistance` `gestures:workspace_swipe_distance` | 300 px | How far a three-finger swipe must travel to change workspace. Range 100–800. |
|
||||
| **Natural swipe direction**<br>`swipeInvert` `gestures:workspace_swipe_invert` | true | Swiping left moves to the workspace on the right, as content follows your fingers |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Input redesign — keycaps, search, and honest state
|
||||
|
||||
Approved mock: `home-mocks/input.html` (scratchpad, :8642). Spec wins over mock on conflict.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Shortcuts browser**: the 130-row wall becomes one searchable card — keycap chips, group
|
||||
headers with counts, hover-revealed Change/Reset, inline capture. The rebinding engine
|
||||
(`Keybinds.rebind`, conflicts refused, `keybindOverrides`) already exists and is untouched.
|
||||
2. **Typing card modernized**: dropdowns instead of ChoiceGrid tile walls; raw XKB string and
|
||||
variant into a collapsed Advanced section (still discoverable — a contract requires the raw
|
||||
string not be hidden *away*, collapsed-but-present satisfies it; C verifies the needle).
|
||||
3. **Mouse & Touchpad**: dropdowns over wide segmented rows, four new option groups (below),
|
||||
a live try-it area, and a phantom-filtered device list.
|
||||
4. **Dictation status-first**: ready hero, hotkeys as keycaps (looked up live from Keybinds so a
|
||||
rebind shows truthfully; literal fallback), guided setup steps with a real progress bar, and
|
||||
an on-page test field that uses the existing typing pipeline.
|
||||
|
||||
Non-goals: per-device settings (phantom-heavy device list, zero plumbing — deliberately
|
||||
skipped), editing keybinds.lua actions (chords only, as today), touch/tablet options
|
||||
(no hardware), accel `custom` curves.
|
||||
|
||||
## New schema keys (group / hypr option — agent A verifies each option's exact name, type, and
|
||||
value map against `hyprctl descriptions` before writing the entry; enum-hypr-map-contract and
|
||||
schema-hypr-shape-contract must hold; every key also gets its `prefs.get()` read-back in
|
||||
`config/dot/hypr/input.lua` with matching defaults — hypr-prefs-contract):
|
||||
|
||||
| Key | Group | Hyprland option | UI |
|
||||
|---|---|---|---|
|
||||
| `focusOnClose` | pointer | `input:focus_on_close` | dropdown: "Most recently used" / "Under the pointer" |
|
||||
| `cursorHideWhileTyping` | pointer | `cursor:hide_on_key_press` | toggle "Hide pointer while typing" |
|
||||
| `cursorWarpOnWorkspaceChange` | pointer | `cursor:warp_on_change_workspace` | toggle "Jump pointer to the focused display" |
|
||||
| `scrollMethod` | pointer | `input:scroll_method` | dropdown (offer only map-published values; include "On a held button" only if the map allows) |
|
||||
| `scrollButton` | pointer | `input:scroll_button` | int row, visible only when scrollMethod is the button one |
|
||||
| `touchpadClickfinger` | touchpad | `input:touchpad:clickfinger_behavior` | toggle "Two-finger right-click" |
|
||||
| `touchpadTapAndDrag` | touchpad | `input:touchpad:tap-and-drag` | toggle "Tap and drag" |
|
||||
|
||||
Defaults = today's effective Hyprland/input.lua values so shipping changes nothing. If an
|
||||
option's published map/type makes a row above impossible as specced (e.g. scroll_method values),
|
||||
implement what the map allows and flag the difference loudly.
|
||||
|
||||
## Service work (A)
|
||||
|
||||
- `InputDevices.qml`: add `realMice` / `realKeyboards` — name-filtered lists (exclude
|
||||
substrings: `consumer-control`, `virtual`, `video-bus`, `power-button`, `webcam`,
|
||||
`audio`, `uinput`, plus dedupe transceiver siblings by prefix), each entry `{ name, pretty }`
|
||||
(pretty = title-cased, dashes to spaces). Keep the existing flat lists and `hasTouchpad`
|
||||
untouched (contract-pinned behavior).
|
||||
- Expose `mainKeyboardLayout` (from the main keyboard's live layout string) for the devices card.
|
||||
|
||||
## UI (B)
|
||||
|
||||
**ShortcutsPage.qml** (title stays "Keyboard"):
|
||||
1. *Typing* card: Layout dropdown (curated common layouts: English (US), English (UK), German,
|
||||
French, Spanish, Nordic…, mapping to `keyboardLayout` codes; a stored code outside the list
|
||||
renders as the raw code and the dropdown offers "Custom…" which reveals Advanced), Caps Lock
|
||||
dropdown, Compose dropdown, Layout-switching dropdown (all four presets keep writing
|
||||
`keyboardOptions` through the existing page-local XKB helpers — xkb-presets-contract),
|
||||
combined Key repeat row (delay + rate sliders), Num Lock toggle, then **Advanced** expander:
|
||||
raw `keyboardOptions` mono field + `keyboardVariant`.
|
||||
2. *Shortcuts* card: header "130 bound · N changed" (live counts), subtitle "Click Change and
|
||||
press the new keys. A shortcut another action holds is refused, never stolen.", filter field
|
||||
(matches description + group, case-insensitive), grouped rows in `Keybinds.groupOrder` with
|
||||
"showing N of M" when filtered; each row: description, CHANGED badge when overridden,
|
||||
hover-revealed Change/Reset (Reset only on overridden), keycap chord. Capture swaps the
|
||||
chord area for the existing `ShortcutCapture` inline. "Restore every shipped shortcut" row
|
||||
stays, with the live differs-count detail. Keep the no-GNOME-handoff comment.
|
||||
3. New component **KeycapChord.qml**: parses a display chord ("SUPER + SHIFT + Q") into keycap
|
||||
chips — mono font, tabular figures, modifier caps tinted accent, "+" separators muted.
|
||||
Reused by DictationPage.
|
||||
|
||||
**MousePage.qml**: Mouse card (speed slow/fast, Acceleration dropdown, Scroll speed, Natural
|
||||
scrolling, Left-handed, Middle-click paste, Scroll method dropdown + conditional Scroll button
|
||||
row) · Touchpad card (existing rows + the two new toggles; visible on `hasTouchpad`) ·
|
||||
Pointer behavior card (Focus dropdown — the 4 followMouse values, focusOnClose dropdown,
|
||||
hide-while-typing, hide-after slider with "Never" zero, warp toggle, Pointer size) ·
|
||||
**Try it** card (new `InputTestArea.qml`: a scribble Canvas — repaints only on pointer motion,
|
||||
cleared by a corner button — and a scrollable text strip; purely local, no compositor writes) ·
|
||||
**Connected devices** card from `InputDevices.realKeyboards/realMice` (+ touchpad when
|
||||
present), with the subtitle noting phantoms are filtered. Gestures rows fold into the Touchpad
|
||||
card (swipe distance + invert) — the separate Gestures card goes.
|
||||
|
||||
**DictationPage.qml**: ready hero (state tile ✓ / … / ✗, title, model+size line) · hotkey rows
|
||||
with KeycapChord, chords looked up from `Keybinds.binds` by matching the dictate descriptions
|
||||
(fallback literals if not found) · **Try it** row: a read-only-styled TextField + "Test
|
||||
dictation" button that focuses the field and drives `panama-dictate start`/`stop` through the
|
||||
existing Dictation service — dictated text lands in the field via the normal wtype pipeline, no
|
||||
new script plumbing; detail explains it types here instead of your document · Microphone
|
||||
ActionRow → Sound (unchanged) · setup state replaces the hero with numbered steps (container
|
||||
image / speech model with progress bar from `downloadFraction` / first transcription), driven
|
||||
by the existing `phase` fields; errors keep their row.
|
||||
|
||||
## Search & docs (C)
|
||||
|
||||
Extra entries: "Rebind a shortcut" → shortcuts; "Key repeat" auto via schema; "Pointer test
|
||||
area" → mouse; "Scroll method" auto; "Connected input devices" → mouse. Existing entries stay.
|
||||
Docs + launcher commands regenerate after schema lands (orchestrator).
|
||||
|
||||
## Contracts (C — write, never run)
|
||||
|
||||
- `xkb-presets-contract`: verify/adjust needles for the Advanced placement (raw string must
|
||||
remain present in the page source).
|
||||
- `keybinds-contract` / `keybind-rebind-contract`: UI needles (Change/Reset/ShortcutCapture
|
||||
usage) reconciled with the rebuilt page; count-match and conflict rules unchanged.
|
||||
- `enum-hypr-map-contract` / `schema-hypr-shape-contract` / `hypr-prefs-contract`: the seven
|
||||
new keys must satisfy all three (C statically replays where possible).
|
||||
- `settings-pages-contract`, `gnome-handoff-contract` needles re-verified.
|
||||
- `dictation-contract`: confirm the test-field flow doesn't violate the "where text lands" pins
|
||||
(it uses the normal pipeline; the page merely owns focus). Flag, don't force, if it conflicts.
|
||||
- Backlog spec: Phase 8 section.
|
||||
|
||||
## Agent ownership (parallel)
|
||||
|
||||
- **A**: `config/PreferenceSchema.qml` (7 new keys), `config/dot/hypr/input.lua`,
|
||||
`services/InputDevices.qml`.
|
||||
- **B**: `modules/settings/ShortcutsPage.qml`, `MousePage.qml`, `DictationPage.qml`, new
|
||||
components (`KeycapChord.qml`, `InputTestArea.qml`, others as needed) + `modules/settings/qmldir`.
|
||||
- **C**: `services/SettingsSearch.qml`, the contracts above, backlog spec, README count line
|
||||
only if the count changes.
|
||||
|
||||
B programs against the schema keys and InputDevices API above; A must not change them without
|
||||
updating this spec.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Notifications & Focus redesign — two tabs, nothing unbounded
|
||||
|
||||
Approved mock: `home-mocks/notifications.html` (scratchpad, :8642). This spec is the
|
||||
implementation contract; where mock and spec disagree, the spec wins.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Bound the app list.** Recent senders + customized apps render up top; everything else sits
|
||||
behind a collapsed, searchable "All apps" expander. Rows expand in place to their controls.
|
||||
2. **Real per-app rules**: sound on/off, banners-vs-history, urgency override, forget.
|
||||
3. **A real focus-mode editor**: create/rename/delete/reorder (order IS priority), trigger-kind
|
||||
editing for all five kinds, schedules with day pills, chip-based interrupt lists.
|
||||
4. **Two tabs**: Notifications | Focus, as category tabs like Shell.
|
||||
|
||||
Non-goals: lock-screen privacy (hyprlock cannot render notifications; contract-banned),
|
||||
per-app counters (no data), time-based history retention, merging duplicate app identities
|
||||
(show the raw id honestly instead).
|
||||
|
||||
## Rule shape (pinned — service and UI program against this)
|
||||
|
||||
`normalizedAppRule` in `Notifs.qml` grows from `{ enabled }` to:
|
||||
|
||||
| Field | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `enabled` | bool | true | Off = rejected before tracking/history/unread/toast (unchanged) |
|
||||
| `sound` | bool | true | false = `playBell` skips this app |
|
||||
| `display` | string | `"banners"` | `"history"` = file in history + unread, no popup, no bell |
|
||||
| `urgency` | string | `"auto"` | `"low"` / `"critical"` override what the app claims |
|
||||
| `lastSeenMs` | int | 0 | stamped in `rememberApplication` on every notification |
|
||||
| `name` | string | "" | display name cached at remember time (resolution stays live-first) |
|
||||
| `icon` | string | "" | icon cached at remember time (DesktopEntries lookup, else `appIcon`) |
|
||||
|
||||
Unknown/stale fields (incl. the old lock-screen pair) keep being dropped on read. Old
|
||||
`{enabled}`-only blobs stay valid — every new field is optional with the defaults above.
|
||||
New API: `forgetApp(appId)` deletes the rule key outright.
|
||||
`effectiveUrgency(notification)` — the app's `urgency` override applied over
|
||||
`notification.urgency`; consumed by `playBell` (low = silent), the popup timeout choice
|
||||
(critical duration), the DND breakthrough gate, and `NotificationCard`'s critical edge.
|
||||
|
||||
**Critical breakthrough**: new schema key `criticalBreaksThrough` (bool, def **false**, group
|
||||
`notifications`, label "Critical alerts break through"). Popup gate becomes: show when
|
||||
`!doNotDisturb || FocusModes.allows(appId) || (Settings.criticalBreaksThrough &&
|
||||
effectiveUrgency(n) === critical)`.
|
||||
|
||||
## FocusModes API additions (pinned)
|
||||
|
||||
- `createMode(name)` → new mode `{ id: unique slug, name, enabled: true, triggers: [{kind:
|
||||
"manual"}], silence: true, keepAwake: false, allow: [] }`, appended (lowest priority).
|
||||
- `removeMode(id)`, `renameMode(id, name)` (non-empty, trimmed).
|
||||
- `moveMode(id, delta)` — reorder; order is priority and the UI says so.
|
||||
- `setTriggerKind(id, kind, fields)` — replaces the mode's `triggers` with one trigger of the
|
||||
new kind. Kind-specific seeds: schedule → `{ start: "22:00", end: "07:00", days: [0..6] }`;
|
||||
workspace → `{ id: 1 }`; fullscreen/game/manual → no fields. (Shipped modes each carry one
|
||||
trigger; a hand-edited multi-trigger mode collapses to one on first kind change — the editor
|
||||
edits `triggers[0]` and that is documented in a comment.)
|
||||
- **Manual-mode semantics**: investigate how a manual-trigger mode activates today and PRESERVE
|
||||
it exactly; if manual modes currently have no activation path besides `enabled`, the header
|
||||
toggle keeps meaning `enabled` and the editor's "Turns on: Manually" detail explains that a
|
||||
manual mode quiets things whenever it is switched on. Do not invent new activation machinery.
|
||||
- Existing `setEnabled`/`update`/`reschedule`-style schedule + day editing semantics stay;
|
||||
`withinWindow`, single-DND-ownership, and the gaming report-don't-silence rule are
|
||||
contract-pinned and untouched.
|
||||
|
||||
## Routing (pinned)
|
||||
|
||||
`SettingsRoutes` category `notifications` gains tabs:
|
||||
`[{ page: "notifications", label: "Notifications" }, { page: "focus", label: "Focus" }]`.
|
||||
New leaf `focus` → new `FocusPage.qml`; `SettingsShell` case + Component. `groupPages`
|
||||
`"focus"` moves `"notifications"` → `"focus"` (the focusModes entry renders there now).
|
||||
`GamingPage`'s "Open Focus" action retargets `openSettings("focus")`.
|
||||
`NotificationCard`'s "Notification settings" jump stays `"notifications"`.
|
||||
|
||||
## Notifications tab (NotificationsPage.qml rebuilt)
|
||||
|
||||
Lede unchanged. Cards:
|
||||
1. **Quiet** — Do Not Disturb toggle; "Critical alerts break through" ToggleRow
|
||||
(`criticalBreaksThrough`); "Quiet hours" ActionRow whose detail states the Sleep mode's
|
||||
live schedule (or "not scheduled" when Sleep lacks/disabled) and whose button opens the
|
||||
Focus tab (`openSettings("focus")`).
|
||||
2. **Banners & history** — the four existing schema sliders (names contract-pinned; critical
|
||||
zero renders "Never"); history count + Clear folded into the history row.
|
||||
3. **Applications** — subtitle per mock. Sections:
|
||||
- *Recent*: apps with `lastSeenMs` within 7 days, newest first.
|
||||
- *Customized*: any app with a non-default field (and not already in Recent).
|
||||
- *All apps (N)*: collapsed expander with an inline search field; alphabetical.
|
||||
Rows: cached icon (fallback letter tile), name, subtitle (relative last-seen when known ·
|
||||
state summary like "sound off"/"History only", else the raw appId), enabled toggle, chevron.
|
||||
Expanded body: Play sound toggle · "Show as" dropdown (Banners & history / History only) ·
|
||||
"Urgency" dropdown (App decides / Treat as low / Treat as critical) · "Forget this app"
|
||||
ActionRow (detail: "Remove its rule; it returns on its next notification").
|
||||
4. **Active-mode banner** at top (ok-tinted) when a focus mode is active: "<name> is quieting
|
||||
notifications · because <reason> · <allow summary>", button "Open Focus".
|
||||
|
||||
## Focus tab (FocusPage.qml, new)
|
||||
|
||||
Lede: "Modes quiet this machine on their own terms — first matching mode wins, and the order
|
||||
below is the priority."
|
||||
1. **Focus modes** card — accordion (one open at a time): drag grip (reorder = priority; also
|
||||
keyboard up/down on the grip), mode glyph, name, summary line ("Turns on <trigger summary> ·
|
||||
silences everything except N apps" / "On now — <reason>"), enabled toggle, chevron.
|
||||
Expanded: "Turns on" dropdown (five kinds) + kind fields (schedule start/end `TimeOfDayRow`-
|
||||
style or validated HH:MM inputs + seven day pills; workspace id picker); "Silence
|
||||
notifications" toggle; "May interrupt" chip row (chips with ×, "+ Add app" opens a searchable
|
||||
picker over known apps — reuse the rules list's app universe); "Keep the screen awake"
|
||||
toggle; Rename + Delete mode buttons. "+ New focus mode" dashed row at the bottom.
|
||||
2. **Focus sessions** card — default duration segmented chips (25/45/60/90 →
|
||||
`focusDurationMinutes`), Caffeine toggle, session status row + Start focus/Show controls
|
||||
(existing behaviors move over unchanged).
|
||||
|
||||
Reuse existing row widgets and the Displays/Sound phase components (OptionPickerRow, etc.)
|
||||
before inventing new ones. No continuously repainting animations. All new components get
|
||||
qmldir lines in the same wave as first reference.
|
||||
|
||||
## Search & docs
|
||||
|
||||
Hand-written entries (page per target): Do Not Disturb, Quiet hours, Critical alerts break
|
||||
through, Application notification rules, Forget an app's notifications, Per-app notification
|
||||
sound → `notifications`; Focus modes detail already schema-indexed (now routes to `focus`),
|
||||
plus Focus session duration → `focus` if not covered by the workspaces group move. Docs and
|
||||
launcher commands regenerate after the schema lands (orchestrator's audit pass).
|
||||
|
||||
## Contracts (write, do NOT run — cite in the backlog for the next sweep)
|
||||
|
||||
- `notification-app-rules-contract`: extend the pinned rule shape to the table above (defaults,
|
||||
optional back-compat, stale-field dropping incl. lock-screen pair), pin `forgetApp`, the
|
||||
display="history" no-popup-no-bell path, sound=false no-bell, `effectiveUrgency` consumers,
|
||||
and the breakthrough gate literal.
|
||||
- `focus-modes-contract`: pin the new CRUD/reorder/trigger APIs, keep every existing pin
|
||||
(conditions-not-alarms, DND ownership, gaming reports, exception list consulted + editable —
|
||||
the editable needle moves to FocusPage).
|
||||
- `settings-pages-contract` (page id list + component list), `settings-jump-contract`
|
||||
(GamingPage → focus), `search-routing-contract` expectations, `settings-window-contract` if
|
||||
it enumerates tabs.
|
||||
- Backlog spec gains a Phase 7 section listing all of it.
|
||||
|
||||
## Agent ownership (parallel)
|
||||
|
||||
- **A — services**: `services/Notifs.qml`, `services/FocusModes.qml`,
|
||||
`config/PreferenceSchema.qml` (one new key; comments above braces).
|
||||
- **B — UI**: `modules/settings/NotificationsPage.qml`, new `modules/settings/FocusPage.qml` +
|
||||
new components + `modules/settings/qmldir`, `services/SettingsRoutes.qml`,
|
||||
`modules/settings/SettingsShell.qml`, `modules/notifications/NotificationCard.qml`
|
||||
(effectiveUrgency), `modules/settings/GamingPage.qml` (Open Focus target).
|
||||
- **C — periphery**: `services/SettingsSearch.qml`, the contracts above + harness fixtures,
|
||||
test-backlog spec, README count line only if the contract count changes.
|
||||
|
||||
B programs against the pinned shapes; A must not change them without updating this spec.
|
||||
Reference in New Issue
Block a user