lab / chord-fn-row  ·  see also fn-row-rethink  ·  supersedes alt-fn-row (the Alt-based variant, rejected: it costs Alt+Space)

CapsLock+Space[number row] — F1–F12

Using the two-trigger chord as the layer selector instead of Alt. Full edge-case audit and blast radius. Nothing implemented yet.

CapsLockX 2.0 · branch beta Rust core + Windows adapter 2026-09-04

The idea is better than the Alt variant — three whole problem classes vanish

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.

But the chord is already taken — and it locks eagerly

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.

Notation

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.

TokenReads asRule
( a+b )groupKeys treated as one unit. + is commutative (交换律) — press order within a group is irrelevant. Parentheses are optional for a single key.
[… goes downThe group to the left is pressed and held. Everything after it happens while it stays down.
]… comes back upThe 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.
ktapA bare key name is struck and released. Several in a row are taps in sequence.
X.tap the groupDown 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 oneAlternation, and commutative like + — but it means one of these, not both of these. (space|capslock) is "whichever trigger you happen to use".
clxabbreviationShort for (space|capslock). clx[ h ] is the familiar "hold a trigger, tap H".
nestingexplicit orderOrder is only expressed where it matters: an inner hold was pressed after the outer one, and is released before it.
X[ unclosednever releasedA hold with no ] is a key that was never let go — the shape of the stuck-modifier bug in M5 / B8.

Why the brackets earn their keep here

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.

The vocabulary, applied

GestureNameReading
Space+CapsLockchordBoth triggers as one group. Names the thing; says nothing yet about when it goes down.
Space+CapsLock.chord tapDown, 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]comboHold the chord, tap 1, release. Emits F1. A non-empty body, so no lock toggle.
Space[3]comboA 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]comboAlt is part of the held group, so it is still down when 4 is struck. Emits Alt+F4.
CapsLock[Alt[Space[1]]]orderedThe 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[ leakedPressed, never released. What a UAC prompt or focus steal leaves in held_keys (B8).
Alt+Spacenot CLXA tapped group with no hold span at all: an ordinary Windows shortcut. Opens the window system menu — the behaviour you asked to keep.

The three modes, written down

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.

NotationModeWhat it means
hCM_NORMALA key is just a key.
Space|CapsLock[h]CM_FNA trigger is physically held. The hold span is bounded by your finger.
Space+CapsLock.  then  Space|CapsLock[ CM_CLXYes — 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.

Two caveats before you rely on that equivalence

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.

When does each part fire?

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.

WrittenFires onConsequence
(s+c)[ 1the 1 key-downF1 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 groupDecides only what the chord meant: empty body → toggle the lock, non-empty body → the body already did the work, so do nothing.

This is why the deferred lock needs no timeout

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.

1What the chord does today

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

Surprise 1 — the chord is not a toggle

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.

Surprise 2 — the unlock branch is dead code

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.

2Proposed mapping & gesture grammar

GestureMeaningStatus
CapsLock+Space.Toggle CLX lockpreserved (becomes a true toggle)
CapsLock+Space[1]…9F1…F9new
CapsLock+Space[0]F10new
CapsLock+Space[-] / CapsLock+Space[=]F11 / F12new — needs KeyCode::Minus/Equal
any modifier + the chord + digitCtrl / Shift / Alt / Win + Fnfree — modifiers are never injected or suppressed
Space[1]–0 (single trigger)Switch virtual desktopuntouched
Space+Shift[1–0]Move window to desktopuntouched
Alt+SpaceWindows window system menuuntouched — the bypass rule stays exactly as it is

3L · Lock-toggle collision

#CaseBehaviourSeverityResolution
L1Chord locks eagerly on key-downCapsLock+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.criticalDefer: 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.
L2Deferring changes existing behaviourToday CapsLock+Space[H] both moves the cursor and leaves you locked. Under the deferred rule it only moves the cursor.decisionRecommend 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.
L3The chord only ever locks, never unlocksenter_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.mediumThe 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.
L4Suspected pre-existing bug: unlocking taps a real CapsLockenter_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.highVerify 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.
L5How long must the chord be held?No timeout needed — "both triggers currently in held_keys" is a precise, instantaneous test.n/aAvoid adding any hold threshold. Timing thresholds are what made C3/T2 below fragile.
L6Using the layer while already lockedFrom 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?mediumDefine 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.

4T · Trigger & ordering

#CaseBehaviourSeverityResolution
T1Chord detection uses prior_key, not held_keysCapsLock[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.criticalDetect 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.
T2Space-first, then CapsLock, slower than 200 msThe 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.highTreat "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.
T3CapsLock-first is cleanThe 200 ms hold timer is spawned only for Space, so CapsLock[Space[  has no race at all.okDocument CapsLock-first as the canonical order; fix T2 anyway so the other order isn't a trap.
T4Release order of the two triggersSafe: clx_up nulls trigger_key on the first release, so the second release matches nothing and emits no stray Space or CapsLock.verified okNo action — but the deferred-lock rewrite must preserve this property.
T5Either trigger disabled in prefsuse_capslock / use_space are independently toggleable. Turn one off and the layer is silently unreachable.mediumGrey out the F-row toggle in prefs with an explanatory line when only one trigger is enabled.
T6Other trigger keys existis_trigger_key also accepts Insert, ScrollLock and RAlt when enabled. Should Insert+Space count as the chord?decisionRecommend strict CapsLock+Space. "Any two enabled triggers" is more general but makes RAlt+Space a chord, which collides with real AltGr use.
T7Auto-repeat of a held triggerHandled: the engine deduplicates repeats via held_keys insertion, so holding the chord doesn't re-enter clx_dn.okNo action.
T8emergency_stop during the chordClears 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.mediumReset the chord flag in emergency_stop. Cheap, but easy to forget.

5K · Keys & layout

#CaseBehaviourSeverityResolution
K1- and = have no KeyCode variantkey_code.rs's punctuation block stops at Slash; both keys currently land in Unknown(vk) and can't be bound.blockerAdd 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.
K2JIS / AZERTY number rowsJIS is 1234567890-^¥ — there is no = key, so F12 is unreachable. AZERTY digits need Shift.mediumAccept 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.
K3Numpad digitsVK_NUMPAD0-9 fall to Unknown today and are inert — but a "bind all digits" implementation would capture them.mediumBind top-row VKs explicitly. Alt+numpad Alt-code entry must survive.
K4is_mapped_key() takes no state or modifiersIt 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 -.mediumMake the predicate chord-aware by giving the module an Arc<ClxState> (as EditModule already has), rather than picking the lesser evil.
K5F-key auto-repeatDigits are already "mapped", so repeats are suppressed: holding CapsLock+Space[5] gives exactly one F5.lowMatches current digit behaviour; ship as-is. Avoids F5-refresh spam.
K6F13–F24No keys left on the row.n/aOut of scope.

6M · Modifiers & rollover

#CaseBehaviourSeverityResolution
M1Modifiers decorate the F-key for freeThe 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 payoffNo code needed. Ctrl+CapsLock+Space[5] = Ctrl+F5, Alt+CapsLock+Space[4] = a real Alt+F4.
M2Shift specificallyArrows 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.okUse the same kbd() injection path; do not add a synthetic Shift.
M3Keyboard rollover / ghostingThe 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.highTest 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.
M4Lone Alt still reaches the appIf you press Alt and don't complete a combo, Windows activates the menu bar — as it always has.noneNot introduced by this feature. No action. (The Alt variant, by contrast, had to solve this.)
M5Stale modifier in held_keysA 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.lowReconcile held_keys against GetAsyncKeyState on the existing 250 ms nudge timer.

7C · Collisions with existing bindings

#Existing behaviourWhereImpactSeverity
C1CLX[1–0] — switch virtual desktopmodules/virtual_desktop.rs:22Fully 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
C2CLX+Shift[1–0] — move window to desktopmodules/virtual_desktop.rs:33Preserved. 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
C3CLX[F5–F11] — media / volume keysmodules/media.rs:19No 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
C4CLX[,] prefs · CLX[/] layout HUDmodules/mod.rs:154Both 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
C5CLX+Ctrl+Alt[X] — kill processmodules/window_manager.rs:66Untouched — and unlike the Alt design, there's no Alt-suppression scheme that could accidentally strip mods.alt out from under it.preserved
C6Alt+Tab / Win+Tab switcher remapsadapters/windows/src/alt_tab.rsRuns 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
C7Space bypass (Shift/Ctrl/Cmd+Space — IME, Spotlight, window menu)core/src/engine.rs:229Untouched — 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
C8Bare CLX[-] / CLX[=]core/src/engine.rs:138Currently unmapped, so they pass through and type -/=. Must keep doing so outside the chord — see K4.medium
C9Test capslock_space_chord_locks_clx_modecore/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
C10Test clx_locked_tap_unlockscore/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

8E · Ergonomics & discoverability

#CaseBehaviourSeverityResolution
E1Hand geometryWithdrawn. 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 issueNo action. See fn-row-rethink §1.5.
E2Accidental chordResting a pinky on CapsLock while doing Space[3] silently turns a desktop switch into F3.mediumInherent to the gesture. Mitigate with HUD feedback (E4) rather than a timing threshold.
E3DiscoverabilityThe CLX[/] HUD, docs/keyboard-layout.svg, README tables and CLAUDE.md's hotkey map all show the number row as virtual-desktop only.lowUpdate alongside. Note the Windows adapter has no toggle_keyboard_layout_hud implementation yet — only macOS and browser do.
E4No feedback that the layer is armedNothing on screen distinguishes "one trigger held" from "chord held", yet the number row means two different things.mediumWorth a small HUD/tray affordance — the tray already tracks mode edges through TRAY_TX, so a third state is cheap.

9P · Platform & plumbing

#CaseBehaviourSeverityResolution
P1Chord state must reach the modulesModules receive only &Modifiers (shift/ctrl/alt/win) — there is no way for a module to know both triggers are held.highAdd 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.
P2macOS paritySame 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.easyVerify the macOS adapter filters its own injected events so CapsLock+Space[5] can't re-enter as media Play/Pause.
P3Linux / browser adaptersWon't compile until Minus/Equal are added; browser also needs HUD glyphs.lowMechanical, compiler-enforced.
P4Prefs plumbingNeeds a fn_row_enabled flag in ClxConfig + config_store + the prefs UI.lowFollow the existing pattern; Windows prefs must stay out-of-process (WebView2 in the hook process kills the hook).
P5Elevated windows (UIPI), games, RDPInjection into an elevated foreground window fails unless clx is elevated; some games read raw HID.lowUnchanged limitations. clx already runs elevated here via the CapsLockX_Rust logon task.

10Alt variant vs chord variant

DimensionAlt + digitCapsLock+Space + digit
Alt+Space window menuLost (or the feature is order-dependent)Kept
Alt+F4 hazardCritical — needs hook-level Alt suppressionNone — Alt is not part of the gesture
Menu-bar activation, AltGr, IME language flip, GetAsyncKeyStateFour separate unknowns to solveAll moot
Collision with CLX+digit desktop switchingSilently steals a working chordNone — distinct gesture
Modifiers on the F-keysCtrl/Shift/Win free; Alt impossible (it's the selector)All four free, Alt included
Collision with the lock toggleNoneCritical — needs the deferred-lock rewrite
Hand geometry for F1–F5Comfortable (left thumb + left hand)Awkward — left hand is pinned
Keyboard rollover demand2 non-modifier keys3 non-modifier keys — must be tested
Engine surgery requiredAlt suppression + bypass rule + timeout raceDeferred lock + chord detection from held_keys + timeout race
VerdictRejected — costs Alt+SpaceRecommended, pending the rollover test (M3) and a hands-on ergonomics check (E1)

11Recommended solution

The one behavioural rule that makes it work

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.

Implementation plan

StepChangeFiles
1Detect the chord from held_keys instead of prior_key (T1), and add chord_active + chord_acted to ClxStatecore/src/engine.rs:196 · core/src/state.rs:218
2Defer 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
3Fix 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 firstcore/src/engine.rs:257, :303
4Fix 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
5Add 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}
6New 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
7Dispatch 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
8Clear 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
9Rewrite 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)
10Prefs toggle, HUD/tray chord indicator, and docs: CLAUDE.md hotkey map, README tables, docs/keyboard-layout.svgcore/src/state.rs · adapters/windows/src/{config_store.rs, prefs/} · CLAUDE.md · docs/

Shape of the gesture logic

// 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);
}

12Decisions I need from you

#QuestionMy recommendation
Q1Accept 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.
Q2Make 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.
Q3Should 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.
Q4Strict CapsLock+Space, or any two enabled trigger keys?Strict. "Any two" makes RAlt+Space a chord, which collides with real AltGr use.
Q5Have 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.
Q6F-key auto-repeat while held?No — matches current digit behaviour, avoids F5 spam. Easy to add later.
Q7JIS ^ (0xDE) as a second source for F12?Yes — one line, and it stops the layer being US-only.

13Verification matrix

ProbeActionPass condition
Any editorCapsLock+Space[1], release, then type hellohello appears literally. The headline test — proves you were not left in locked mode (L1).
NotepadCapsLock+Space[5]F5 inserts a date/time stamp — positive proof of a bare F5.
Explorer, file selectedCapsLock+Space[2]F2 starts rename.
ChromeCapsLock+Space[=]F12 opens DevTools (K1/K2).
Any appAlt held, then CapsLock+Space[4]Window closes — Alt+F4 works on purpose (M1).
Any appCapsLock[Alt[Space[1]]]Alt+F1, not the window system menu (T1).
Any appAlt+Space aloneWindow system menu still opens (C7) — the thing you asked to keep.
Any appSpace down, wait 1 s, then CapsLock[1]F1 only — no leading literal space, no repeat burst (T2).
Any appCapsLock+Space tapped clean, twiceLocks, then unlocks. Caps Lock LED never changes (L3, L4).
Any appSpace[3] (single trigger)Still switches to virtual desktop 3 (C1).
Any appSpace+Shift[3]Still moves the window to desktop 3 (C2).
Any appCapsLock+Space[3]F3 only — the desktop does not also switch (C2).
EditorCapsLock+Space[-] held downOne F11; no stream of - characters (K4).
EditorBare CLX[-] heldStill types repeating - (C8).
Keyboard matrixCtrl+Shift+CapsLock+Space[5]Ctrl+Shift+F5 registers — no ghosting (M3). Test this before writing code.
NumpadAlt+numpad 65, no triggersStill types A — Alt codes intact (K3).