lab / fn-row-rethink · notation: chord-fn-row · recognizer: gesture-parser
Writing the recognizer changed what the design question even is. Five options, compared in the language rather than in prose.
Building the parser settled the chord's safety question — an empty body is structurally distinct from a non-empty one, so the lock toggle can never collide with the F-layer and needs no timeout. But it also made the alternation (space|capslock) visible on the page, and once you can see it you have to ask why it is there: the two triggers are redundant, and that redundancy is an entire unused layer. Spending it is cheaper than the chord and needs no state-machine surgery at all.
These two are different shapes, not different timings:
| Gesture | Body | Meaning |
|---|---|---|
(capslock+space). | empty | Toggle the CLX lock |
(capslock+space)[ 1 ] | one tap | F1 — the proposal |
Every key in a body fires on its own key-down, so by the time the closing bracket arrives, "was the body empty?" is already answered. No hold threshold, no clock, no guessing at intent — which is exactly where the existing 200 ms Space timeout goes wrong. The chord design is safe by construction, not by tuning.
The recognizer normalises every group to + because which finger lifts first is not something a person controls. The engine should hold the same view — but today it does not quite: clx_up acts only when trigger == Some(code), and trigger_key is nulled by the first release. Behaviour therefore depends on release order. It happens to be benign now; it is fragile the moment a second gesture depends on the same edges.
A tempting option the notation surfaces is a sub-layer: clx[ f[ 1 ] ] — hold a trigger, hold a layer key, strike a digit. The body can contain a hold, so the language expresses it fine. The keyboard does not:
| Module | Claims |
|---|---|
| edit | H J K L Y U I O G T P N Enter Backspace |
| mouse | W A S D Q E R F |
| window_manager | Z X C . |
| media | F5–F11 [ ] \ |
| virtual_desktop | 0 1 2 3 4 5 6 7 8 9 |
| brainstorm / agent / voice | B · M · V |
| mod.rs | , / |
Every letter A–Z is taken. A sub-layer prefix would have to evict something — F is mouse scroll — so that branch is closed. What is genuinely free: -, =, ;, ', `, and the second trigger.
(space|capslock) is a wasted layerWriting the alternation down is what exposed it. The two triggers are interchangeable everywhere: space[ h ] and capslock[ h ] do the identical thing, and is_trigger_key treats them as one. That redundancy is not free — it is a whole second layer of the entire keyboard, sitting unused, and it costs no new key to spend.
I claimed the chord strands F1–F5 on the wrong hand. That is wrong, and the counter-example is a posture I had not considered: pinky and ring finger take the whole left column — CapsLock, Shift, Ctrl are stacked there — the thumb takes Space, and because nothing is anchored on home row the middle and index fingers roam the number row freely, reaching 1–5 without the right hand moving at all.
So the reach objection to design B is withdrawn, and B's score improves from third to a close second. The same posture is what makes design D comfortable too — D just asks for fewer fingers.
| # | Gesture for F1 | Keys down | Costs | Verdict |
|---|---|---|---|---|
| A | (clx+alt)[ 1 ] | 3 | Loses Alt+Space, or becomes order-dependent. Needs hook-level Alt suppression to avoid Alt+F4 closing the window, plus the menu-bar, AltGr and GetAsyncKeyState unknowns. Silently steals (clx+alt)[ 3 ], which already switches desktop today. |
rejected |
| B | (capslock+space)[ 1 ] | 3 | Collides with the lock gesture (capslock+space). — resolvable by construction (§1.1), but it forces the deferred-lock rewrite, chord detection from held_keys, and the 200 ms race fix. Three non-modifier keys, so rollover still wants one measurement. |
close 2nd |
| C | clx[ f[ 1 ] ] | 3 | A sub-layer. The language expresses it; the keyboard cannot host it — every letter is claimed (§1.3), and F is mouse scroll. | closed |
| D | capslock[ 1 ] | 2 | Split the triggers: CapsLock carries the F-row, Space keeps the virtual desktops. Costs the trigger-symmetry invariant, and capslock[ 3 ] stops switching desktop. |
recommended |
| E | clx[ ; 1 ] | 2 | A tapped prefix inside the hold, vim-style. Uses a genuinely free key, but introduces modal state: a pending prefix needs a timeout or an explicit reset, and a mistimed second key does the wrong thing silently. | fallback |
| Gesture | Emits | Status |
|---|---|---|
capslock[ 1 ] … capslock[ = ] | F1 … F12 | new |
(capslock+shift)[ 1 ] | Shift+F1 | free — modifiers decorate |
(capslock+ctrl)[ 5 ] | Ctrl+F5 | free |
(capslock+alt)[ 4 ] | Alt+F4, on purpose | free |
space[ 3 ] | switch to desktop 3 | unchanged |
(space+shift)[ 3 ] | move window to desktop 3 | unchanged |
(capslock+space). | toggle CLX lock | unchanged |
alt+space | window system menu | unchanged |
capslock[ h ] | cursor left — every other binding still works on both triggers | unchanged |
chord_active plumbing. The engine already records which trigger is held; the module just has to read it.With the reach objection withdrawn, B is genuinely close. The trade is now clean: B pays once in engine work (and gets the L4 and T2 bug fixes bundled in) to preserve the mental model; D pays forever in a learned asymmetry to get a two-key gesture and near-zero implementation risk. I still lean D on the keyspace argument, but this is a preference call, not a technical one.
Trigger symmetry. Today the two triggers are interchangeable and you never think about which one you grabbed. Afterwards, the number row means two different things depending on that choice. Everything else stays symmetric, so the asymmetry is confined to one row — but it is a real thing to learn, and it is the whole price of the design.
capslock[ 3 ] stops switching desktop. If that is in your fingers, this will bite for a week.
Locked mode has no trigger held, so active_trigger is None and a bare digit has to pick a layer. Keep the status quo — desktops — which means the F-row is unavailable while locked.
If you disable CapsLock as a trigger, the layer disappears. Prefs should say so.
| Change | D (split) | B (chord) | Notes |
|---|---|---|---|
KeyCode::Minus / Equal + 4 adapter maps | needed | needed | core/src/key_code.rs · adapters/{windows,macos,linux,browser} |
New FnRowModule | needed | needed | core/src/modules/fn_row.rs |
| Expose which trigger is held | needed | — | Mirror trigger_key from ClxEngine into ClxState; modules already hold an Arc<ClxState>. |
Gate virtual_desktop on the trigger | needed | needed | Explicit early-return, not dispatch order. |
| Deferred lock rewrite | — | needed | Chord must stop locking eagerly on key-down. |
Chord detection from held_keys | — | needed | prior_key is unreliable once a modifier is pressed between the triggers. |
| 200 ms Space-timeout race | — | needed | Reaching for the second trigger too slowly types a space. |
| Alt suppression at the hook | — | — | Only design A needed this. Good riddance. |
| L4 CapsLock-leak fix | optional | bundled | Independent bug either way — worth landing on its own. |
Design D is roughly the first two rows plus one accessor. Design B is the whole table.
| # | Question | My answer |
|---|---|---|
| Q1 | Is losing trigger symmetry acceptable? It is the entire price of D. | Yes — the redundancy buys a layer, and the asymmetry is confined to the number row. But it is your muscle memory, so it is your call. |
| Q2 | Which trigger gets the F-row? | CapsLock. Space is the one you hold while typing, so leaving the more disruptive layer on the less-used trigger is safer. |
| Q3 | What should (capslock+space)[ 1 ] do — both triggers held, non-empty body? | Emit F1. CapsLock is present, and the body is non-empty so it is not a lock request. Costs nothing to allow. |
| Q4 | Should the L4 CapsLock leak be fixed first, independently? | Yes. It is a live bug in the current build regardless of which design lands — please check whether your Caps Lock LED toggles when you exit locked mode. |
| Q5 | Still worth measuring rollover? | Not for D — two keys. For B yes, and it is now the only open objection to it: your posture proves the reach, but not that five simultaneous keys all register. One minute on the parser page settles it. |
| Q6 | Given the correction, do you want B after all? | Defensible now. If trigger symmetry is worth more to you than two keystrokes' worth of comfort and a week of my time, say so and I will plan B instead. |