lab / alt-fn-row  ·  see also fn-row-rethink  ·  superseded by chord-fn-row — this Alt-based variant was rejected because it costs Alt+Space.

CLX + Alt + number row → F1–F12

Edge-case audit, blast radius on the existing hotkey layers, and a recommended implementation plan. Nothing implemented yet.

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

Two findings dominate everything else

1. CLX+Alt+4 would close the focused window. The engine never suppresses a physical Alt, so an injected F4 lands on the app as Alt+F4. Same class: Alt+F1, and a bare Alt transition activating the menu bar/ribbon.

2. Alt pressed before Space never reaches CLX at all. clx_dn bypasses Space whenever any modifier is held, so the OS gets Alt+Space (window system menu) instead. Only "Space first, then Alt" works today, and only for the Space trigger — CapsLock is fine either order.

1Proposed mapping

Trigger = CapsLock or Space (either, as today). The layer only exists while Alt is also held.

ChordEmitsKey (US)Notes
CLX+Alt+1…9F1…F9VK 0x31–0x39Straight positional map
CLX+Alt+0F10VK 0x30Bare F10 activates the menu bar in Win32 apps — that is F10's behaviour, not a bug
CLX+Alt+-F11VK_OEM_MINUS 0xBDNew KeyCode::Minus needed
CLX+Alt+=F12VK_OEM_PLUS 0xBBNew KeyCode::Equal needed; JIS keyboards have ^ here instead (see A2)
+ Shift / Ctrl / WinShift/Ctrl/Win + Fn—Free: those modifiers stay physically held and decorate the injected key (the documented Shift model in engine.rs)

2Edge cases

A · Key coverage & keyboard layout

#CaseNaive behaviourSeverityResolution
A1- and = have no KeyCode variantWon't compile / can't be bound. key_code.rs stops at Slash.blockerAdd Minus+Equal to the core enum and to all four adapter tables. The exhaustive match in each keycode_to_vk makes the compiler find every site for you.
A2JIS / AZERTY / non-US number rowsJIS has 1234567890-^¥: there is no = key, so F12 is unreachable. AZERTY digits require Shift.mediumEither also accept VK_OEM_7 (0xDE) for F12, or bind by scancode 0x02–0x0D which is layout-independent. Your current layout is 00000409 (US), so this is a ship-blocker only for other users.
A3Numpad digitsVK_NUMPAD0-9 currently fall to KeyCode::Unknown, so they're inert — but a "bind all digits" implementation would grab them.mediumExplicitly bind top-row VKs only. Alt+numpad is Windows Alt-code character entry and must survive.
A4is_mapped_key() has no mods parameterIf Minus/Equal report "mapped", the engine suppresses their auto-repeat while CLX is active — holding CLX+- stops typing -.mediumKeep is_mapped_key(Minus|Equal) == false, or thread modifiers into the predicate. Digits are already "mapped" (virtual desktop), so no new regression there.
A5F13–F24Out of scope; no keys left on the row.n/aNote only.

B · The Alt problem highest risk cluster

#CaseNaive behaviourSeverityResolution
B1Physical Alt decorates the injected F-keyCLX+Alt+4 → app receives Alt+F4 → window closes. Alt+F1, Alt+F7 etc. likewise hit app shortcuts.criticalAlt must be neutralised before injection — see the recommended strategy S1 below. This single case decides the design.
B2Lone Alt down→up activates the menu barIn Office / Explorer / Chrome / Firefox, an Alt transition with no intervening keystroke focuses the menu or ribbon. Injecting "Alt↑ then F1" produces exactly that pattern.highDon't inject an Alt release after the OS already saw Alt down. Suppress the Alt key-down at the hook so the OS never sees it at all.
B3Save-and-restore Alt (release, tap F, re-press) re-arms B2The user's later physical Alt-up then reads as a lone Alt tap → menu bar again.highRules out the obvious "isolate at injection time" approach as the primary strategy. Keep it only as a fallback for platforms where hook-level suppression isn't possible.
B4Alt held → digits arrive as WM_SYSKEYDOWNWould be dropped if the hook only handled WM_KEYDOWN.verified okAlready handled: hook.rs treats WM_SYSKEYDOWN/WM_SYSKEYUP as pressed/released. Prerequisite satisfied.
B5AltGr = Ctrl+RAlt on EU layoutsHolding RAlt reports alt&&ctrl, so the layer would fire and be decorated with Ctrl, while the layout expects AltGr to produce special characters.mediumDecide whether RAlt participates. Safest: LAlt only, or "alt && !ctrl". Not an issue on US 00000409.
B6Apps that poll GetAsyncKeyState(VK_MENU)Games and some editors read raw device state rather than the message queue, and may still see Alt down even after the hook suppresses the event.unknownMust be verified empirically with a probe app before shipping — do not assume either way.
B7Alt+Shift is the Windows input-language switchCLX+Alt+Shift+1 can flip the IME language as a side effect.mediumSuppressing Alt at the hook fixes this for free — a small bonus of S1.
B8Stale Alt in held_keysA UAC prompt / secure desktop / focus steal can eat the Alt key-up. modifier_held then stays true forever → Space permanently bypasses and stops being a trigger.high (latent)Pre-existing, but this feature makes users hold Alt constantly, so it will start biting. Add a periodic reconciliation of held_keys against GetAsyncKeyState on the existing 250 ms nudge timer.

C · Trigger & engine interaction

#CaseNaive behaviourSeverityResolution
C1Alt pressed before Spaceclx_dn: bypass = modifier_held for Space → passes through → Windows opens the window system menu. The whole feature is unreachable in that order.highNarrow the bypass set: shift || ctrl || win || (alt && !fn_row_enabled). Costs Alt+Space (window menu) — worth a pref.
C2Alt pressed before CapsLockWorks. The bypass rule for non-Space triggers tests non-modifier keys only, so a held Alt doesn't bypass CapsLock.okAsymmetry worth documenting: CapsLock is order-free, Space is not.
C3200 ms Space-hold timeout races the Alt pressThe modifier_held guard is evaluated once at t=200 ms. Press Space, pause, then Alt+1 → a literal space is typed and starts auto-repeating first.mediumBreak the repeat loop when fn_acted flips true, and re-check modifier_held inside the loop rather than only before it.
C4CLX-locked mode (CapsLock+Space chord)No trigger is held, yet CLX is active — so bare Alt+1 fires F1, and if Alt were suppressed here, Alt would be globally dead until unlock.highScope Alt suppression to FN mode (trigger physically held) only, never to locked mode. Decide separately whether the layer itself is active in locked mode (recommend: yes, but without Alt suppression).
C5Auto-repeat of the F-keyDigits are already is_mapped_key, so the engine suppresses repeats: holding CLX+Alt+5 gives exactly one F5.lowMatches current digit behaviour. Ship as-is; add repeat later if you miss it (F5-spam is arguably a feature you don't want).
C6emergency_stop mid-chordClears held_keys, so a held Alt is forgotten and the next digit falls through to virtual-desktop switching.lowAcceptable — it's the secure-input escape hatch. No action.
C7Tap-through semanticsA bare Space+Alt press with no digit does nothing at all (Space is swallowed, Alt is suppressed). Slightly surprising.lowAcceptable and predictable. Alternative — replay a real Alt tap on release if unused — re-introduces B2. Don't.

D · Platform & surface area

#CaseNaive behaviourSeverityResolution
E1macOS: Option+F-keys are system-reservedOption+F1/F2 open Display settings, Option+F10–F12 open Sound settings — the exact B1 failure in Mac clothing.mediumClear flags explicitly with CGEventSetFlags(evt, 0) on the injected event; verify the CGEventTap doesn't re-add the physical Option flag.
E2Linux / browser adaptersWon't compile until Minus/Equal are added to linux/key_map.rs (evdev 12/13) and browser/key_map.rs ("Minus"/"Equal", plus HUD glyphs).lowMechanical. Compiler-enforced.
E3Prefs / config plumbingNew toggles need ClxConfig + config_store + the out-of-process clx prefs-window UI (Slint on Windows, HTML on macOS).lowFollow the existing pattern; remember Windows prefs must stay out-of-process (WebView2 in the hook process kills the hook).
E4DiscoverabilityThe CLX+/ keyboard-layout 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/browser do.
E5Elevated foreground windows (UIPI)A non-elevated clx cannot inject into an elevated window — F-keys silently do nothing there.lowAlready fine on this machine: the CapsLockX_Rust logon task runs clx elevated. Not true for plain-launch users.
E6Games / RDP / DirectInputSome full-screen games ignore SendInput or read raw HID.lowSame limitation as every existing CLX binding. Note only.

3Blast radius on existing hotkey systems

Everything the number row, Alt, and the Space trigger already touch.

#Existing behaviourWhereImpactSeverity
D1CLX+1–0 → switch virtual desktop 1–10modules/virtual_desktop.rs:22Directly overlapped. mods.alt is currently ignored, so CLX+Alt+3 already switches to desktop 3 today — the new layer silently steals a working chord. Fix explicitly: early-return from VirtualDesktopModule::on_key_down when mods.alt, and dispatch the Fn-row module before it in Modules::on_key_down. Relying on dispatch order alone is too subtle to survive future edits.high
D2CLX+Shift+1–0 → move window to desktopmodules/virtual_desktop.rs:33Unchanged when Alt is absent. Ambiguity when both are held: is CLX+Alt+Shift+3 "Shift+F3" or "move window to desktop 3"? Recommend Alt wins — Alt is the explicit layer selector, Shift is a decorator. That yields the full Shift/Ctrl/Win+Fn set for free, and move-window keeps its own unambiguous chord.decision
D3CLX+F5–F11 → media / volume keysmodules/media.rs:19No loop on Windows: injected events carry CLX_EXTRA_INFO and the hook skips them, so an emitted F5 cannot come back as Play/Pause. Verify the same filtering exists on macOS and Linux before shipping. Mental-model clash remains: CLX+F5 is Play/Pause while CLX+Alt+5 is a real F5 — worth one line in the docs.medium
D4CLX+Ctrl+Alt+X → kill processmodules/window_manager.rs:66Alt is already a live in-CLX modifier. If Alt suppression removes it from held_keys rather than merely from the OS, mods.alt goes false and this chord dies. Rule: suppress the event, keep the tracking.high
D5Alt+Tab / Win+Tab switcher remaps (WASD→arrows, media pad, X/C→close)adapters/windows/src/alt_tab.rsRuns before engine dispatch on every Alt-down key-down, gated on a focused switcher window. Digits aren't in its remap set, so no collision. But suppressing Alt while a trigger is held means you can't start Alt+Tab from inside CLX — you'd release Space first. Low real-world cost: a switcher is rarely focused while a trigger is held.low
D6Desktop-switch keystroke fallback (Win+Ctrl+←/→)adapters/windows/src/output.rs:606Latent bug the new feature makes reachable: with Alt physically held, the fallback sends Alt+Win+Ctrl+← and misfires. Also applies to navigate_desktops. Alt neutralisation fixes an existing bug here — the same contamination affects any CLX action taken while Alt happens to be down.medium
D7Space bypass rule (Shift/Ctrl/Cmd+Space → IME, Spotlight)core/src/engine.rs:229Changing the rule for Alt is the C1 fix. Shift/Ctrl/Win bypasses must stay exactly as they are — they're IME and Spotlight. Only the Alt arm moves.high
D8CLX+, prefs and CLX+/ layout HUDcore/src/modules/mod.rs:154Checked before every module and ignore modifiers entirely — untouched. Worth noting they'd also fire with Alt held, which is harmless.none
D9Bare CLX+- / CLX+=core/src/engine.rs:138Currently unmapped, so they pass through and type -/=. Must keep doing so when Alt is absent — see A4 about is_mapped_key.medium
D10Engine unit testscore/src/engine.rs (tests)shift_space_bypasses_to_pass_through and friends stay green. There is no Alt+Space bypass test today — the C1 change needs a new one asserting the opposite for Alt. is_mapped_key_reflects_module_mapping asserts !is_mapped_key(F1); unaffected since we emit F-keys, never consume them.low

4Recommended solution

Strategy choice for Alt neutralisation

S1 — Suppress Alt at the hook recommended

While in FN mode (a trigger physically held), return Suppress for LAlt/RAlt down and up, but keep them in held_keys. The OS never sees Alt: no Alt+F4, no menu bar, no WM_SYSKEYDOWN, no IME language flip. Fixes D6's latent bug too.

S2 — Isolate at injection time fallback

Batch [Alt↑, Fn↓, Fn↑] in one SendInput. Simpler, but hits B2/B3 — the injected Alt↑ reads as a lone Alt tap and pops the menu bar. Needs the AHK "dummy Ctrl tap" trick to defuse, which is fragile. Keep only for platforms where hook suppression isn't available.

Why S1 is the right default

It is the only option that makes CLX+Alt+4 provably safe rather than probabilistically safe, and it retroactively fixes every existing CLX chord that misfires when Alt happens to be held (D6). The cost — no starting Alt+Tab while a trigger is held — is small and reversible.

Implementation plan

StepChangeFiles
1Add KeyCode::Minus and KeyCode::Equal; wire all four adapter maps (US VKs + JIS 0xDE fallback for F12)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}
2New FnRowModule: returns false unless mods.alt; maps digit/minus/equal → F1–F12; keeps is_mapped_key false for Minus/Equalcore/src/modules/fn_row.rs (new) · modules/mod.rs
3Dispatch fn_row before virtual_desktop, and add an explicit if mods.alt { return false } guard inside VirtualDesktopModule::on_key_downcore/src/modules/mod.rs:166 · modules/virtual_desktop.rs:22
4Alt suppression (S1): in on_key_event, when is_fn_mode() and the code is LAlt/RAlt — record in held_keys, return Suppress. Never in locked mode (C4).core/src/engine.rs:96
5Narrow the Space bypass so Alt no longer bypasses when the feature is on (C1); add a regression testcore/src/engine.rs:229
6Fix the 200 ms timeout race: re-check fn_acted/modifier_held inside the auto-repeat loop (C3)core/src/engine.rs:246
7Reconcile held_keys against GetAsyncKeyState on the existing 250 ms nudge timer, to clear a stale Alt (B8)adapters/windows/src/hook.rs:186
8Prefs: fn_row_enabled (default on) and alt_space_bypass (default off when the layer is on)core/src/state.rs · adapters/windows/src/{config_store.rs, prefs/}
9Docs: hotkey map in CLAUDE.md, README tables, docs/keyboard-layout.svg, HUDCLAUDE.md · docs/

Shape of the module

// core/src/modules/fn_row.rs
pub fn on_key_down(&self, key: KeyCode, mods: &Modifiers) -> bool {
    if !mods.alt { return false; }          // layer is Alt-gated
    let f = match key {
        KeyCode::D1 => KeyCode::F1,  /* … */  KeyCode::D9 => KeyCode::F9,
        KeyCode::D0 => KeyCode::F10,
        KeyCode::Minus => KeyCode::F11,
        KeyCode::Equal => KeyCode::F12,
        _ => return false,
    };
    // Alt is already suppressed at the hook (S1), so a plain tap is clean;
    // physically-held Shift/Ctrl/Win decorate it for free.
    self.platform.key_tap(f);
    true
}

5Decisions I need from you

#QuestionMy recommendation
Q1Sacrifice Alt+Space (Windows window system menu) so Alt-then-Space works, or keep the bypass and require Space-then-Alt order?Sacrifice it, behind a pref. An order-dependent hotkey is a bug report waiting to happen; the window menu is reachable from the title bar.
Q2CLX+Alt+Shift+3 — Shift+F3, or keep "move window to desktop 3"?Shift+F3. Alt is the layer selector and wins; move-window keeps its own unambiguous chord.
Q3Should F-keys auto-repeat while held?No — matches current digit behaviour and avoids F5-spam. Trivial to add later.
Q4Should the layer work in CLX-locked mode (bare Alt+1 after the CapsLock+Space chord)?Yes for the layer, no for Alt suppression — suppressing Alt globally while locked would be far too invasive.
Q5Does RAlt/AltGr participate?LAlt only, or alt && !ctrl. Costs nothing on your US layout and avoids breaking EU users.
Q6Bind by VK or by scancode?VK now, with a JIS 0xDE fallback for F12. Scancode is more correct but touches the injection path too; not worth it for a single-layout user today.

6Verification matrix

The first row is the one that decides whether this ships.

ProbeActionPass condition
Notepad, unsaved fileCLX+Alt+4Window does NOT close. An F4 arrives (Notepad ignores it); no save prompt.
NotepadCLX+Alt+5F5 inserts a date/time stamp — clean positive proof of a bare F5.
Explorer, file selectedCLX+Alt+2F2 starts rename; the ribbon does not receive focus (B2).
ChromeCLX+Alt+=F12 opens DevTools; no menu-bar flash.
VS CodeCLX+Alt+1F1 opens the command palette (not "focus editor group 1", which is Alt+1).
Any appAlt down → Space down → 1F1, not the window system menu (C1).
Any appSpace down, wait 1 s, then Alt+1F1 only — no leading literal space, no repeat burst (C3).
Any appCLX+3 with no AltStill switches to virtual desktop 3 (D1).
Any appCLX+Shift+3Still moves the window to desktop 3 (D2).
Any appCLX+Ctrl+Alt+XStill kills the process — proves suppression kept held_keys tracking (D4).
Word or ExcelHold CLX+Alt, release both, press nothingNo ribbon key-tips appear (B2/B3).
Key-state probe appCLX+Alt heldRecord what GetAsyncKeyState(VK_MENU) reports — settles B6 one way or the other.
EditorBare CLX+- heldStill types repeating - (A4/D9).
NumpadAlt+numpad 65 (no trigger)Still types A — Alt codes intact (A3).