lab / chord-fn-row · see also fn-row-rethink · supersedes alt-fn-row (the Alt-based variant, rejected: it costs Alt+Space)
Using the two-trigger chord as the layer selector instead of Alt. Full edge-case audit and blast radius. Nothing implemented yet.
1. Alt+Space keeps its Windows window-menu behaviour; the Space bypass rule is untouched. 2. No Alt suppression, so no Alt+F4 hazard, no menu-bar activation, no AltGr problem, no GetAsyncKeyState unknown. 3. CLX[1–0] (virtual desktop) and CLX+Shift[1–0] (move window) are preserved exactly, because a two-trigger chord is a genuinely different gesture rather than a modifier the desktop module was ignoring.
And your intuition about modifiers is right: Ctrl/Shift/Alt/Win stay physically held and decorate the injected F-key for free. Alt+CapsLock+Space[4] is a real Alt+F4 — now a feature rather than an accident.
You're right that CapsLock+Space is the CLX-lock gesture, and the collision is worse than a naming clash: clx_dn calls enter_clx_mode() the instant the second trigger goes down, before any digit is pressed. So CapsLock+Space[1] would emit F1 and leave you in locked CLX mode — your next hello becomes cursor-left, page-nav, and friends. Every single use of the F-layer would strand you in locked mode.
The fix is a one-word change in discipline: make the lock deferred — decided on release, and skipped when the chord was consumed by another key. That is exactly the fn_acted tap-vs-hold rule CLX already applies to a single trigger.
A gesture is a timeline, not a set of keys — and every bug in this document lives on a press or release edge. So the notation makes those edges explicit: (…) groups keys, [ is the moment the group goes down, ] is the moment it comes back up, and a bare key name in between is a tap.
| Token | Reads as | Rule |
|---|---|---|
( a+b ) | group | Keys treated as one unit. + is commutative (交换律) — press order within a group is irrelevant. Parentheses are optional for a single key. |
[ | … goes down | The group to the left is pressed and held. Everything after it happens while it stays down. |
] | … comes back up | The hold ends. All keys in the group must be released — letting go of just one does not close the bracket. This is the rule the deferred lock toggle turns on. |
k | tap | A bare key name is struck and released. Several in a row are taps in sequence. |
X. | tap the group | Down and straight back up with nothing in between — sugar for an empty body, X[ ]. space. is a literal space character; (s+c). is the lock toggle. |
( a|b ) | either one | Alternation, and commutative like + — but it means one of these, not both of these. (space|capslock) is "whichever trigger you happen to use". |
clx | abbreviation | Short for (space|capslock). clx[ h ] is the familiar "hold a trigger, tap H". |
| nesting | explicit order | Order is only expressed where it matters: an inner hold was pressed after the outer one, and is released before it. |
X[ unclosed | never released | A hold with no ] is a key that was never let go — the shape of the stuck-modifier bug in M5 / B8. |
The whole proposal turns on release semantics: the lock toggle must fire on the release of the last trigger, a partial release must not count, and the release order of the two triggers decides whether a stray character escapes (T4). A flat "hold-plus-key" notation cannot say any of that. This one says it in punctuation.
| Gesture | Name | Reading |
|---|---|---|
| Space+CapsLock | chord | Both triggers as one group. Names the thing; says nothing yet about when it goes down. |
| Space+CapsLock. | chord tap | Down, nothing struck, back up — the . is sugar for an empty body. That emptiness is precisely the condition for toggling the CLX lock, and it opens the unclosed hold below. The F-layer must not steal this. |
| Space+CapsLock[1] | combo | Hold the chord, tap 1, release. Emits F1. A non-empty body, so no lock toggle. |
| Space[3] | combo | A single trigger held. Switches to virtual desktop 3 — visibly a different gesture from the chord above, which is why the two can coexist. |
| Alt+CapsLock+Space[4] | combo | Alt is part of the held group, so it is still down when 4 is struck. Emits Alt+F4. |
| CapsLock[Alt[Space[1]]] | ordered | The same three keys, but pressed in that order — and that order is the T1 failure: Space arrives with Alt as the previous key, so the chord is never detected. |
| Alt[ | leaked | Pressed, never released. What a UAC prompt or focus steal leaves in held_keys (B8). |
| Alt+Space | not CLX | A tapped group with no hold span at all: an ordinary Windows shortcut. Opens the window system menu — the behaviour you asked to keep. |
Your question — whether a locked session behaves exactly like a held trigger — has a precise answer in the code, and the notation turns out to say it neatly. on_key_event step 4 gates on is_clx_active(), which is true for both CM_FN and CM_CLX, so a non-trigger key is dispatched through the identical path either way.
| Notation | Mode | What it means |
|---|---|---|
| h | CM_NORMAL | A key is just a key. |
| Space|CapsLock[h] | CM_FN | A trigger is physically held. The hold span is bounded by your finger. |
| Space+CapsLock. then Space|CapsLock[ | CM_CLX | Yes — locked mode is the same hold span, just unclosed. The chord tap opens a hold that outlives your fingers; every later key lands in the same dispatch path as CM_FN. The lock is not a separate behaviour, it is a bracket nobody closed. |
You cannot nest a hold inside the lock. Pressing a trigger while locked runs enter_fn_mode(), which does mode &= !CM_CLX — the outer bracket is torn up by the inner press rather than nesting inside it. So clx[ … clx[ … ] … ] is unwritable, by construction.
That is also the mechanism behind L4. Because the lock bit is already gone by the time clx_up tests is_clx_locked(), the "unlock instead of emitting" branch never runs and a real CapsLock is tapped instead. In notation: the ] you meant to write silently became capslock. — an actual keystroke leaking into your document, and your Caps Lock LED with it.
One more consequence worth stating plainly: in locked mode space. both types a space and closes the lock, because Space is a trigger and is consumed by step 3 before the module dispatch of step 4 ever sees it.
The bracket is not a trigger condition. A key in the body acts on its own key-down, the moment it is struck — modules.on_key_down is called from step 4 of on_key_event, long before anything is released. Only the closing bracket waits.
| Written | Fires on | Consequence |
|---|---|---|
(s+c)[ 1 | the 1 key-down | F1 is already emitted. The ] is not needed to make the combo work — you may leave it off when the point is that the action has happened regardless of how the hold ends. |
…] | release of the last key in the group | Decides only what the chord meant: empty body → toggle the lock, non-empty body → the body already did the work, so do nothing. |
Because every key in the body has already fired by the time ] arrives, "was the body empty?" is answered by then — no hold threshold, no timer, no guessing at intent. That is the one property that makes L1 implementable as a clean rule rather than a heuristic, and it is exactly where the existing 200 ms Space timeout (T2) went wrong by guessing on a clock instead.
Typed plainly: (s+c). taps the chord, (s+c)[ 1 ] is the proposed F1, (s|c)[ h ] is any ordinary CLX binding, capslock[ alt[ space[ 1 ] ] ] is the T1 failure, and clx[ on its own is locked mode. All ASCII — this has to survive being typed into CLAUDE.md, a commit message, a Rust doc comment and the HUD.
Traced through rs/core/src/engine.rs and rs/core/src/state.rs. Two surprises are hiding in here.
// engine.rs :: clx_dn — chord is detected from prior_key, and locks immediately
let chord = (code == CapsLock && prior == Space) || (code == Space && prior == CapsLock);
if chord {
self.state.enter_clx_mode(); // <- LOCKS on key-DOWN, before any digit
self.store_trigger(code);
self.fn_acted.store(true, ...); // <- so release never re-evaluates
return false;
}
// state.rs :: the mode bits
enter_fn_mode() { mode |= CM_FN; mode &= !CM_CLX; } // <- pressing a trigger CLEARS the lock
enter_clx_mode() { mode |= CM_CLX; } // <- always sets; never toggles
enter_clx_mode() is fetch_or: pressing the chord while already locked just locks again. What actually unlocks is a single trigger tap. So the gesture is asymmetric: chord to lock, tap to unlock.
clx_up tests is_clx_locked() to decide "unlock instead of emitting the native key" — but enter_fn_mode() already cleared CM_CLX on the key-down. The test is always false, so the else branch runs and taps a real CapsLock. See L4.
| Gesture | Meaning | Status |
|---|---|---|
| CapsLock+Space. | Toggle CLX lock | preserved (becomes a true toggle) |
| CapsLock+Space[1]…9 | F1…F9 | new |
| CapsLock+Space[0] | F10 | new |
| CapsLock+Space[-] / CapsLock+Space[=] | F11 / F12 | new — needs KeyCode::Minus/Equal |
| any modifier + the chord + digit | Ctrl / Shift / Alt / Win + Fn | free — modifiers are never injected or suppressed |
| Space[1]–0 (single trigger) | Switch virtual desktop | untouched |
| Space+Shift[1–0] | Move window to desktop | untouched |
| Alt+Space | Windows window system menu | untouched — the bypass rule stays exactly as it is |
| # | Case | Behaviour | Severity | Resolution |
|---|---|---|---|---|
| L1 | Chord locks eagerly on key-down | CapsLock+Space[1] emits F1 and strands you in locked CLX mode. Every subsequent letter is a CLX binding until you notice and tap a trigger. | critical | Defer: on chord, enter an active-but-unlatched state; on release of the last trigger, toggle the lock only if chord_acted == false. Mirrors the existing single-trigger fn_acted rule. |
| L2 | Deferring changes existing behaviour | Today CapsLock+Space[H] both moves the cursor and leaves you locked. Under the deferred rule it only moves the cursor. | decision | Recommend accepting: "a chord that did something is not a lock request" is the more defensible rule, and it's what makes the F-layer possible at all. |
| L3 | The chord only ever locks, never unlocks | enter_clx_mode() is fetch_or. Unlocking is a different gesture (single-trigger tap), so the mental model "chord toggles the lock" is not what the code does. | medium | The deferred rewrite is the natural place to make it a genuine toggle — lock if unlocked, unlock if locked. Behaviour change; call it out in the changelog. |
| L4 | Suspected pre-existing bug: unlocking taps a real CapsLock | enter_fn_mode() clears CM_CLX on the key-down, so clx_up's is_clx_locked() check is always false and the else branch fires platform.key_tap(CapsLock) — toggling the OS Caps Lock LED every time you leave locked mode. The clx_locked_tap_unlocks test passes for the wrong reason. | high | Verify on the running build first (does your Caps Lock light come on when you exit locked mode?), then fix while the lock logic is open. Snapshot the locked flag before enter_fn_mode clears it. |
| L5 | How long must the chord be held? | No timeout needed — "both triggers currently in held_keys" is a precise, instantaneous test. | n/a | Avoid adding any hold threshold. Timing thresholds are what made C3/T2 below fragile. |
| L6 | Using the layer while already locked | From locked mode, pressing CapsLock calls enter_fn_mode() which clears the lock, then Space forms the chord and re-locks. Net effect after a digit: ambiguous — do you come back to locked, or to normal? | medium | Define it: the chord layer should restore the pre-chord lock state when it was consumed by an action. Requires snapshotting the lock bit on chord entry. |
| # | Case | Behaviour | Severity | Resolution |
|---|---|---|---|---|
| T1 | Chord detection uses prior_key, not held_keys | CapsLock[Alt[Space[ ]]] fails to form the chord, because prior is now Alt. It falls through to the Space bypass (a modifier is held) and Windows opens the window menu. Since "modifiers work naturally" is the whole point of this design, this breaks it for every modified chord where the modifier is pressed second. | critical | Detect the chord from held_keys — "is the other trigger currently down?". The bypass check three lines below already does exactly this, and its comment explains why prior is unreliable. |
| T2 | Space-first, then CapsLock, slower than 200 ms | The Space hold-timeout thread fires: a literal space is typed and starts auto-repeating, then the chord forms. A deliberate three-key chord is exactly the slow, aimed motion that trips this. | high | Treat "a second trigger went down" as fn_acted, and re-check inside the auto-repeat loop rather than only before it. Needed regardless of this feature. |
| T3 | CapsLock-first is clean | The 200 ms hold timer is spawned only for Space, so CapsLock[Space[ has no race at all. | ok | Document CapsLock-first as the canonical order; fix T2 anyway so the other order isn't a trap. |
| T4 | Release order of the two triggers | Safe: clx_up nulls trigger_key on the first release, so the second release matches nothing and emits no stray Space or CapsLock. | verified ok | No action — but the deferred-lock rewrite must preserve this property. |
| T5 | Either trigger disabled in prefs | use_capslock / use_space are independently toggleable. Turn one off and the layer is silently unreachable. | medium | Grey out the F-row toggle in prefs with an explanatory line when only one trigger is enabled. |
| T6 | Other trigger keys exist | is_trigger_key also accepts Insert, ScrollLock and RAlt when enabled. Should Insert+Space count as the chord? | decision | Recommend strict CapsLock+Space. "Any two enabled triggers" is more general but makes RAlt+Space a chord, which collides with real AltGr use. |
| T7 | Auto-repeat of a held trigger | Handled: the engine deduplicates repeats via held_keys insertion, so holding the chord doesn't re-enter clx_dn. | ok | No action. |
| T8 | emergency_stop during the chord | Clears held_keys and exits both modes — the chord state must be cleared with it, or a stale "chord active" flag makes plain digits emit F-keys. | medium | Reset the chord flag in emergency_stop. Cheap, but easy to forget. |
| # | Case | Behaviour | Severity | Resolution |
|---|---|---|---|---|
| K1 | - and = have no KeyCode variant | key_code.rs's punctuation block stops at Slash; both keys currently land in Unknown(vk) and can't be bound. | blocker | Add Minus + Equal to the core enum and to all four adapter tables (windows/vk.rs 0xBD/0xBB, macos/key_map.rs 0x1B/0x18, linux/key_map.rs 12/13, browser/key_map.rs "Minus"/"Equal"). The exhaustive matches make the compiler list every site. |
| K2 | JIS / AZERTY number rows | JIS is 1234567890-^¥ — there is no = key, so F12 is unreachable. AZERTY digits need Shift. | medium | Accept VK_OEM_7 (0xDE) as a second F12 source, or bind by scancode 0x02–0x0D (layout-independent). Your machine reports layout 00000409 (US), so this only blocks other users. |
| K3 | Numpad digits | VK_NUMPAD0-9 fall to Unknown today and are inert — but a "bind all digits" implementation would capture them. | medium | Bind top-row VKs explicitly. Alt+numpad Alt-code entry must survive. |
| K4 | is_mapped_key() takes no state or modifiers | It gates auto-repeat suppression. If Minus/Equal report unmapped, holding CapsLock+Space[-] leaks a stream of - into the app. If they report mapped, bare CLX[-] stops repeating a literal -. | medium | Make the predicate chord-aware by giving the module an Arc<ClxState> (as EditModule already has), rather than picking the lesser evil. |
| K5 | F-key auto-repeat | Digits are already "mapped", so repeats are suppressed: holding CapsLock+Space[5] gives exactly one F5. | low | Matches current digit behaviour; ship as-is. Avoids F5-refresh spam. |
| K6 | F13–F24 | No keys left on the row. | n/a | Out of scope. |
| # | Case | Behaviour | Severity | Resolution |
|---|---|---|---|---|
| M1 | Modifiers decorate the F-key for free | The engine never injects or suppresses Ctrl/Alt/Win, and deliberately doesn't touch Shift either. A physically-held modifier applies to the injected scancode directly. | the payoff | No code needed. Ctrl+CapsLock+Space[5] = Ctrl+F5, Alt+CapsLock+Space[4] = a real Alt+F4. |
| M2 | Shift specifically | Arrows are injected by scancode with KEYEVENTF_EXTENDEDKEY precisely so a physical Shift modifies them without the OS isolating it — the same mechanism carries F-keys. | ok | Use the same kbd() injection path; do not add a synthetic Shift. |
| M3 | Keyboard rollover / ghosting | The base chord is already 3 simultaneous keys; add Ctrl+Shift and it's 5–6. Membrane keyboards routinely fail past 3–4 non-modifier keys, and CapsLock+Space+digit are three non-modifier keys in the matrix. | high | Test on the actual keyboard before building. Your device reports only as "HID Keyboard Device", so the matrix is unknown. This is the one risk that could kill the design outright. |
| M4 | Lone Alt still reaches the app | If you press Alt and don't complete a combo, Windows activates the menu bar — as it always has. | none | Not introduced by this feature. No action. (The Alt variant, by contrast, had to solve this.) |
| M5 | Stale modifier in held_keys | A UAC prompt or focus steal can eat a modifier's key-up. Less damaging here than in the Alt design (no bypass rule depends on it), but mods can still lie. | low | Reconcile held_keys against GetAsyncKeyState on the existing 250 ms nudge timer. |
| # | Existing behaviour | Where | Impact | Severity |
|---|---|---|---|---|
| C1 | CLX[1–0] — switch virtual desktop | modules/virtual_desktop.rs:22 | Fully preserved. A single trigger keeps switching desktops; the chord is a separate gesture. This is the design's decisive advantage — the Alt variant silently stole CLX+Alt[3], which already worked as "desktop 3" because the module ignores mods.alt. | preserved |
| C2 | CLX+Shift[1–0] — move window to desktop | modules/virtual_desktop.rs:33 | Preserved. But dispatch order matters: the F-row module must run before virtual_desktop, and virtual_desktop must early-return while the chord is active — otherwise CapsLock+Space[3] both emits F3 and switches desktop. Belt and braces; don't rely on ordering alone. | high |
| C3 | CLX[F5–F11] — media / volume keys | modules/media.rs:19 | No feedback loop on Windows: injected events carry CLX_EXTRA_INFO and the hook skips them, so an emitted F5 cannot return as Play/Pause. Verify the same filter exists on macOS and Linux. The mental-model clash remains — CLX[F5] is Play/Pause while CapsLock+Space[5] is a real F5 — worth one documentation line. | medium |
| C4 | CLX[,] prefs · CLX[/] layout HUD | modules/mod.rs:154 | Both are checked ahead of every module and ignore all state, so CapsLock+Space[,] still opens Preferences. Harmless, but it means , and / can never join the chord layer. | low |
| C5 | CLX+Ctrl+Alt[X] — kill process | modules/window_manager.rs:66 | Untouched — and unlike the Alt design, there's no Alt-suppression scheme that could accidentally strip mods.alt out from under it. | preserved |
| C6 | Alt+Tab / Win+Tab switcher remaps | adapters/windows/src/alt_tab.rs | Runs before engine dispatch, gated on a focused switcher + held Alt; digits aren't in its set. No collision — and because Alt is never suppressed, starting Alt+Tab from inside CLX still works. Strictly better than the Alt variant. | preserved |
| C7 | Space bypass (Shift/Ctrl/Cmd+Space — IME, Spotlight, window menu) | core/src/engine.rs:229 | Untouched — this is what you asked for. Alt+Space keeps opening the Windows window menu. Only T1's chord-detection source changes, and that runs before the bypass check. | preserved |
| C8 | Bare CLX[-] / CLX[=] | core/src/engine.rs:138 | Currently unmapped, so they pass through and type -/=. Must keep doing so outside the chord — see K4. | medium |
| C9 | Test capslock_space_chord_locks_clx_mode | core/src/engine.rs (tests) | Asserts the eager lock and will fail under the deferred design. That failure is the honest signal that L1/L2 is a real behaviour change, not an incidental one — rewrite it to assert "clean chord tap toggles the lock; chord + digit does not". | medium |
| C10 | Test clx_locked_tap_unlocks | core/src/engine.rs (tests) | Passes today only because enter_fn_mode clears CM_CLX — it does not actually exercise the unlock branch (L4). Any rewrite must add an assertion that no stray CapsLock is emitted. | medium |
| # | Case | Behaviour | Severity | Resolution |
|---|---|---|---|---|
| E1 | Hand geometry | Withdrawn. I claimed the left hand is pinned and F1–F5 need the right hand. It is not: pinky and ring take the left column (CapsLock/Shift/Ctrl are stacked there), the thumb takes Space, and since nothing is anchored on home row the middle and index fingers reach 1–5 freely. | not an issue | No action. See fn-row-rethink §1.5. |
| E2 | Accidental chord | Resting a pinky on CapsLock while doing Space[3] silently turns a desktop switch into F3. | medium | Inherent to the gesture. Mitigate with HUD feedback (E4) rather than a timing threshold. |
| E3 | Discoverability | The CLX[/] HUD, docs/keyboard-layout.svg, README tables and CLAUDE.md's hotkey map all show the number row as virtual-desktop only. | low | Update alongside. Note the Windows adapter has no toggle_keyboard_layout_hud implementation yet — only macOS and browser do. |
| E4 | No feedback that the layer is armed | Nothing on screen distinguishes "one trigger held" from "chord held", yet the number row means two different things. | medium | Worth a small HUD/tray affordance — the tray already tracks mode edges through TRAY_TX, so a third state is cheap. |
| # | Case | Behaviour | Severity | Resolution |
|---|---|---|---|---|
| P1 | Chord state must reach the modules | Modules receive only &Modifiers (shift/ctrl/alt/win) — there is no way for a module to know both triggers are held. | high | Add a chord_active: AtomicBool to ClxState, maintained from held_keys on every trigger event, and hand the module an Arc<ClxState>. Cleaner than widening Modifiers, which every module would then have to ignore. |
| P2 | macOS parity | Same core logic, no adapter-specific work beyond the key map — a real advantage over the Alt design, which needed CGEventSetFlags surgery to dodge Option+F-key system shortcuts. | easy | Verify the macOS adapter filters its own injected events so CapsLock+Space[5] can't re-enter as media Play/Pause. |
| P3 | Linux / browser adapters | Won't compile until Minus/Equal are added; browser also needs HUD glyphs. | low | Mechanical, compiler-enforced. |
| P4 | Prefs plumbing | Needs a fn_row_enabled flag in ClxConfig + config_store + the prefs UI. | low | Follow the existing pattern; Windows prefs must stay out-of-process (WebView2 in the hook process kills the hook). |
| P5 | Elevated windows (UIPI), games, RDP | Injection into an elevated foreground window fails unless clx is elevated; some games read raw HID. | low | Unchanged limitations. clx already runs elevated here via the CapsLockX_Rust logon task. |
| Dimension | Alt + digit | CapsLock+Space + digit |
|---|---|---|
| Alt+Space window menu | Lost (or the feature is order-dependent) | Kept |
| Alt+F4 hazard | Critical — needs hook-level Alt suppression | None — Alt is not part of the gesture |
Menu-bar activation, AltGr, IME language flip, GetAsyncKeyState | Four separate unknowns to solve | All moot |
| Collision with CLX+digit desktop switching | Silently steals a working chord | None — distinct gesture |
| Modifiers on the F-keys | Ctrl/Shift/Win free; Alt impossible (it's the selector) | All four free, Alt included |
| Collision with the lock toggle | None | Critical — needs the deferred-lock rewrite |
| Hand geometry for F1–F5 | Comfortable (left thumb + left hand) | Awkward — left hand is pinned |
| Keyboard rollover demand | 2 non-modifier keys | 3 non-modifier keys — must be tested |
| Engine surgery required | Alt suppression + bypass rule + timeout race | Deferred lock + chord detection from held_keys + timeout race |
| Verdict | Rejected — costs Alt+Space | Recommended, pending the rollover test (M3) and a hands-on ergonomics check (E1) |
A chord that did something is not a lock request. CapsLock+Space. toggles the CLX lock only when it is released without any other key having been pressed — the same tap-vs-hold discipline fn_acted already applies to a single trigger. Everything else follows from that.
| Step | Change | Files |
|---|---|---|
| 1 | Detect the chord from held_keys instead of prior_key (T1), and add chord_active + chord_acted to ClxState | core/src/engine.rs:196 · core/src/state.rs:218 |
| 2 | Defer the lock: chord key-down no longer calls enter_clx_mode(); the last trigger release toggles the lock only when !chord_acted (L1–L3), restoring the pre-chord lock state otherwise (L6) | core/src/engine.rs:196, :290 |
| 3 | Fix the dead unlock branch: snapshot the locked flag before enter_fn_mode() clears it, so unlocking stops emitting a real CapsLock (L4) — verify the symptom first | core/src/engine.rs:257, :303 |
| 4 | Fix the Space hold-timeout race: treat a second trigger as fn_acted, and re-check inside the auto-repeat loop (T2) | core/src/engine.rs:246 |
| 5 | Add KeyCode::Minus/Equal across the core enum and all four adapter maps, plus the JIS 0xDE fallback for F12 (K1, K2) | core/src/key_code.rs · adapters/{windows/src/vk.rs, macos/src/key_map.rs, linux/src/key_map.rs, browser/src/key_map.rs} |
| 6 | New FnRowModule holding Arc<ClxState>: fires only while chord_active; maps digit/minus/equal to F1–F12 via a plain key_tap; chord-aware is_mapped_key (K4) | core/src/modules/fn_row.rs (new) · modules/mod.rs |
| 7 | Dispatch fn_row before virtual_desktop, and early-return from VirtualDesktopModule::on_key_down while the chord is active (C2) | core/src/modules/mod.rs:166 · modules/virtual_desktop.rs:22 |
| 8 | Clear the chord flags in emergency_stop (T8); reconcile held_keys against GetAsyncKeyState on the 250 ms nudge timer (M5) | core/src/engine.rs:160 · adapters/windows/src/hook.rs:186 |
| 9 | Rewrite the two lock tests to assert the new grammar (C9, C10); add tests for chord+digit, chord+modifier, and "no stray space/CapsLock" | core/src/engine.rs (tests) |
| 10 | Prefs toggle, HUD/tray chord indicator, and docs: CLAUDE.md hotkey map, README tables, docs/keyboard-layout.svg | core/src/state.rs · adapters/windows/src/{config_store.rs, prefs/} · CLAUDE.md · docs/ |
// engine.rs :: clx_dn — chord from held_keys, and NO eager lock
let other = if code == CapsLock { Space } else { CapsLock };
let chord = matches!(code, CapsLock | Space) && self.held_keys.lock().unwrap().contains(&other);
if chord {
self.state.set_chord_active(true);
self.state.set_chord_was_locked(self.state.is_clx_locked()); // L6
self.state.enter_fn_mode(); // active, but NOT latched
self.store_trigger(code);
self.fn_acted.store(true, ...); // suppresses the native Space/CapsLock tap
return false; // never bypass -> Alt+Space rule untouched
}
// engine.rs :: clx_up — decide the lock on release of the LAST trigger
if self.state.chord_active() && !self.other_trigger_still_held(code) {
if self.state.chord_acted() {
self.state.restore_pre_chord_lock(); // chord was consumed -> no toggle
} else {
self.toggle_clx_lock(); // clean chord tap -> real toggle
}
self.state.set_chord_active(false);
}
| # | Question | My recommendation |
|---|---|---|
| Q1 | Accept the deferred lock — CapsLock+Space toggles the lock only when released with no other key pressed? | Yes. Without it, every use of the F-layer strands you in locked mode. Side effect: CapsLock+Space[H] no longer locks as well as moving the cursor. |
| Q2 | Make the chord a genuine toggle (it currently only ever locks; unlocking is a separate single-tap)? | Yes — matches how you already describe it, and it's the natural place to fix L4. |
| Q3 | Should I verify L4 (does unlocking toggle your physical Caps Lock LED?) before touching anything? | Yes — first thing. If confirmed it's an independent bug fix worth landing on its own. |
| Q4 | Strict CapsLock+Space, or any two enabled trigger keys? | Strict. "Any two" makes RAlt+Space a chord, which collides with real AltGr use. |
| Q5 | Have you tried the three-key chord on this keyboard yet (M3)? | Please test CapsLock+Space[5] and Ctrl+Shift+CapsLock+Space[5] before I build anything — ghosting would sink the design. |
| Q6 | F-key auto-repeat while held? | No — matches current digit behaviour, avoids F5 spam. Easy to add later. |
| Q7 | JIS ^ (0xDE) as a second source for F12? | Yes — one line, and it stops the layer being US-only. |
| Probe | Action | Pass condition |
|---|---|---|
| Any editor | CapsLock+Space[1], release, then type hello | hello appears literally. The headline test — proves you were not left in locked mode (L1). |
| Notepad | CapsLock+Space[5] | F5 inserts a date/time stamp — positive proof of a bare F5. |
| Explorer, file selected | CapsLock+Space[2] | F2 starts rename. |
| Chrome | CapsLock+Space[=] | F12 opens DevTools (K1/K2). |
| Any app | Alt held, then CapsLock+Space[4] | Window closes — Alt+F4 works on purpose (M1). |
| Any app | CapsLock[Alt[Space[1]]] | Alt+F1, not the window system menu (T1). |
| Any app | Alt+Space alone | Window system menu still opens (C7) — the thing you asked to keep. |
| Any app | Space down, wait 1 s, then CapsLock[1] | F1 only — no leading literal space, no repeat burst (T2). |
| Any app | CapsLock+Space tapped clean, twice | Locks, then unlocks. Caps Lock LED never changes (L3, L4). |
| Any app | Space[3] (single trigger) | Still switches to virtual desktop 3 (C1). |
| Any app | Space+Shift[3] | Still moves the window to desktop 3 (C2). |
| Any app | CapsLock+Space[3] | F3 only — the desktop does not also switch (C2). |
| Editor | CapsLock+Space[-] held down | One F11; no stream of - characters (K4). |
| Editor | Bare CLX[-] held | Still types repeating - (C8). |
| Keyboard matrix | Ctrl+Shift+CapsLock+Space[5] | Ctrl+Shift+F5 registers — no ghosting (M3). Test this before writing code. |
| Numpad | Alt+numpad 65, no triggers | Still types A — Alt codes intact (K3). |