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.
Edge-case audit, blast radius on the existing hotkey layers, and a recommended implementation plan. Nothing implemented yet.
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.
Trigger = CapsLock or Space (either, as today). The layer only exists while Alt is also held.
| Chord | Emits | Key (US) | Notes |
|---|---|---|---|
| CLX+Alt+1…9 | F1…F9 | VK 0x31–0x39 | Straight positional map |
| CLX+Alt+0 | F10 | VK 0x30 | Bare F10 activates the menu bar in Win32 apps — that is F10's behaviour, not a bug |
| CLX+Alt+- | F11 | VK_OEM_MINUS 0xBD | New KeyCode::Minus needed |
| CLX+Alt+= | F12 | VK_OEM_PLUS 0xBB | New KeyCode::Equal needed; JIS keyboards have ^ here instead (see A2) |
| + Shift / Ctrl / Win | Shift/Ctrl/Win + Fn | — | Free: those modifiers stay physically held and decorate the injected key (the documented Shift model in engine.rs) |
| # | Case | Naive behaviour | Severity | Resolution |
|---|---|---|---|---|
| A1 | - and = have no KeyCode variant | Won't compile / can't be bound. key_code.rs stops at Slash. | blocker | Add 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. |
| A2 | JIS / AZERTY / non-US number rows | JIS has 1234567890-^¥: there is no = key, so F12 is unreachable. AZERTY digits require Shift. | medium | Either 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. |
| A3 | Numpad digits | VK_NUMPAD0-9 currently fall to KeyCode::Unknown, so they're inert — but a "bind all digits" implementation would grab them. | medium | Explicitly bind top-row VKs only. Alt+numpad is Windows Alt-code character entry and must survive. |
| A4 | is_mapped_key() has no mods parameter | If Minus/Equal report "mapped", the engine suppresses their auto-repeat while CLX is active — holding CLX+- stops typing -. | medium | Keep is_mapped_key(Minus|Equal) == false, or thread modifiers into the predicate. Digits are already "mapped" (virtual desktop), so no new regression there. |
| A5 | F13–F24 | Out of scope; no keys left on the row. | n/a | Note only. |
| # | Case | Naive behaviour | Severity | Resolution |
|---|---|---|---|---|
| B1 | Physical Alt decorates the injected F-key | CLX+Alt+4 → app receives Alt+F4 → window closes. Alt+F1, Alt+F7 etc. likewise hit app shortcuts. | critical | Alt must be neutralised before injection — see the recommended strategy S1 below. This single case decides the design. |
| B2 | Lone Alt down→up activates the menu bar | In Office / Explorer / Chrome / Firefox, an Alt transition with no intervening keystroke focuses the menu or ribbon. Injecting "Alt↑ then F1" produces exactly that pattern. | high | Don'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. |
| B3 | Save-and-restore Alt (release, tap F, re-press) re-arms B2 | The user's later physical Alt-up then reads as a lone Alt tap → menu bar again. | high | Rules 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. |
| B4 | Alt held → digits arrive as WM_SYSKEYDOWN | Would be dropped if the hook only handled WM_KEYDOWN. | verified ok | Already handled: hook.rs treats WM_SYSKEYDOWN/WM_SYSKEYUP as pressed/released. Prerequisite satisfied. |
| B5 | AltGr = Ctrl+RAlt on EU layouts | Holding RAlt reports alt&&ctrl, so the layer would fire and be decorated with Ctrl, while the layout expects AltGr to produce special characters. | medium | Decide whether RAlt participates. Safest: LAlt only, or "alt && !ctrl". Not an issue on US 00000409. |
| B6 | Apps 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. | unknown | Must be verified empirically with a probe app before shipping — do not assume either way. |
| B7 | Alt+Shift is the Windows input-language switch | CLX+Alt+Shift+1 can flip the IME language as a side effect. | medium | Suppressing Alt at the hook fixes this for free — a small bonus of S1. |
| B8 | Stale Alt in held_keys | A 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. |
| # | Case | Naive behaviour | Severity | Resolution |
|---|---|---|---|---|
| C1 | Alt pressed before Space | clx_dn: bypass = modifier_held for Space → passes through → Windows opens the window system menu. The whole feature is unreachable in that order. | high | Narrow the bypass set: shift || ctrl || win || (alt && !fn_row_enabled). Costs Alt+Space (window menu) — worth a pref. |
| C2 | Alt pressed before CapsLock | Works. The bypass rule for non-Space triggers tests non-modifier keys only, so a held Alt doesn't bypass CapsLock. | ok | Asymmetry worth documenting: CapsLock is order-free, Space is not. |
| C3 | 200 ms Space-hold timeout races the Alt press | The 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. | medium | Break the repeat loop when fn_acted flips true, and re-check modifier_held inside the loop rather than only before it. |
| C4 | CLX-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. | high | Scope 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). |
| C5 | Auto-repeat of the F-key | Digits are already is_mapped_key, so the engine suppresses repeats: holding CLX+Alt+5 gives exactly one F5. | low | Matches current digit behaviour. Ship as-is; add repeat later if you miss it (F5-spam is arguably a feature you don't want). |
| C6 | emergency_stop mid-chord | Clears held_keys, so a held Alt is forgotten and the next digit falls through to virtual-desktop switching. | low | Acceptable — it's the secure-input escape hatch. No action. |
| C7 | Tap-through semantics | A bare Space+Alt press with no digit does nothing at all (Space is swallowed, Alt is suppressed). Slightly surprising. | low | Acceptable and predictable. Alternative — replay a real Alt tap on release if unused — re-introduces B2. Don't. |
| # | Case | Naive behaviour | Severity | Resolution |
|---|---|---|---|---|
| E1 | macOS: Option+F-keys are system-reserved | Option+F1/F2 open Display settings, Option+F10–F12 open Sound settings — the exact B1 failure in Mac clothing. | medium | Clear flags explicitly with CGEventSetFlags(evt, 0) on the injected event; verify the CGEventTap doesn't re-add the physical Option flag. |
| E2 | Linux / browser adapters | Won'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). | low | Mechanical. Compiler-enforced. |
| E3 | Prefs / config plumbing | New toggles need ClxConfig + config_store + the out-of-process clx prefs-window UI (Slint on Windows, HTML on macOS). | low | Follow the existing pattern; remember Windows prefs must stay out-of-process (WebView2 in the hook process kills the hook). |
| E4 | Discoverability | The 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. | low | Update alongside. Note the Windows adapter has no toggle_keyboard_layout_hud implementation yet — only macOS/browser do. |
| E5 | Elevated foreground windows (UIPI) | A non-elevated clx cannot inject into an elevated window — F-keys silently do nothing there. | low | Already fine on this machine: the CapsLockX_Rust logon task runs clx elevated. Not true for plain-launch users. |
| E6 | Games / RDP / DirectInput | Some full-screen games ignore SendInput or read raw HID. | low | Same limitation as every existing CLX binding. Note only. |
Everything the number row, Alt, and the Space trigger already touch.
| # | Existing behaviour | Where | Impact | Severity |
|---|---|---|---|---|
| D1 | CLX+1–0 → switch virtual desktop 1–10 | modules/virtual_desktop.rs:22 | Directly 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 |
| D2 | CLX+Shift+1–0 → move window to desktop | modules/virtual_desktop.rs:33 | Unchanged 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 |
| D3 | CLX+F5–F11 → media / volume keys | modules/media.rs:19 | No 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 |
| D4 | CLX+Ctrl+Alt+X → kill process | modules/window_manager.rs:66 | Alt 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 |
| D5 | Alt+Tab / Win+Tab switcher remaps (WASD→arrows, media pad, X/C→close) | adapters/windows/src/alt_tab.rs | Runs 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 |
| D6 | Desktop-switch keystroke fallback (Win+Ctrl+←/→) | adapters/windows/src/output.rs:606 | Latent 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 |
| D7 | Space bypass rule (Shift/Ctrl/Cmd+Space → IME, Spotlight) | core/src/engine.rs:229 | Changing 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 |
| D8 | CLX+, prefs and CLX+/ layout HUD | core/src/modules/mod.rs:154 | Checked before every module and ignore modifiers entirely — untouched. Worth noting they'd also fire with Alt held, which is harmless. | none |
| D9 | Bare CLX+- / CLX+= | core/src/engine.rs:138 | Currently unmapped, so they pass through and type -/=. Must keep doing so when Alt is absent — see A4 about is_mapped_key. | medium |
| D10 | Engine unit tests | core/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 |
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.
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.
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.
| Step | Change | Files |
|---|---|---|
| 1 | Add 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} |
| 2 | New FnRowModule: returns false unless mods.alt; maps digit/minus/equal → F1–F12; keeps is_mapped_key false for Minus/Equal | core/src/modules/fn_row.rs (new) · modules/mod.rs |
| 3 | Dispatch fn_row before virtual_desktop, and add an explicit if mods.alt { return false } guard inside VirtualDesktopModule::on_key_down | core/src/modules/mod.rs:166 · modules/virtual_desktop.rs:22 |
| 4 | Alt 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 |
| 5 | Narrow the Space bypass so Alt no longer bypasses when the feature is on (C1); add a regression test | core/src/engine.rs:229 |
| 6 | Fix the 200 ms timeout race: re-check fn_acted/modifier_held inside the auto-repeat loop (C3) | core/src/engine.rs:246 |
| 7 | Reconcile held_keys against GetAsyncKeyState on the existing 250 ms nudge timer, to clear a stale Alt (B8) | adapters/windows/src/hook.rs:186 |
| 8 | Prefs: 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/} |
| 9 | Docs: hotkey map in CLAUDE.md, README tables, docs/keyboard-layout.svg, HUD | CLAUDE.md · docs/ |
// 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
}
| # | Question | My recommendation |
|---|---|---|
| Q1 | Sacrifice 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. |
| Q2 | CLX+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. |
| Q3 | Should F-keys auto-repeat while held? | No — matches current digit behaviour and avoids F5-spam. Trivial to add later. |
| Q4 | Should 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. |
| Q5 | Does RAlt/AltGr participate? | LAlt only, or alt && !ctrl. Costs nothing on your US layout and avoids breaking EU users. |
| Q6 | Bind 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. |
The first row is the one that decides whether this ships.
| Probe | Action | Pass condition |
|---|---|---|
| Notepad, unsaved file | CLX+Alt+4 | Window does NOT close. An F4 arrives (Notepad ignores it); no save prompt. |
| Notepad | CLX+Alt+5 | F5 inserts a date/time stamp — clean positive proof of a bare F5. |
| Explorer, file selected | CLX+Alt+2 | F2 starts rename; the ribbon does not receive focus (B2). |
| Chrome | CLX+Alt+= | F12 opens DevTools; no menu-bar flash. |
| VS Code | CLX+Alt+1 | F1 opens the command palette (not "focus editor group 1", which is Alt+1). |
| Any app | Alt down → Space down → 1 | F1, not the window system menu (C1). |
| Any app | Space down, wait 1 s, then Alt+1 | F1 only — no leading literal space, no repeat burst (C3). |
| Any app | CLX+3 with no Alt | Still switches to virtual desktop 3 (D1). |
| Any app | CLX+Shift+3 | Still moves the window to desktop 3 (D2). |
| Any app | CLX+Ctrl+Alt+X | Still kills the process — proves suppression kept held_keys tracking (D4). |
| Word or Excel | Hold CLX+Alt, release both, press nothing | No ribbon key-tips appear (B2/B3). |
| Key-state probe app | CLX+Alt held | Record what GetAsyncKeyState(VK_MENU) reports — settles B6 one way or the other. |
| Editor | Bare CLX+- held | Still types repeating - (A4/D9). |
| Numpad | Alt+numpad 65 (no trigger) | Still types A — Alt codes intact (A3). |