feat: add StickyMod (SM) action for Alt+Tab-style modifier cycling#859
feat: add StickyMod (SM) action for Alt+Tab-style modifier cycling#859ldsands wants to merge 72 commits into
Conversation
Size Report
|
|
There is One Shot Sticky Modifier in main branch, what's the difference? |
OSM(LAlt) releases the modifier after one keypress — press OSM, release, press Tab, Alt+Tab fires once and Alt is gone. SM(Tab, LAlt) bundles the modifier and key together and keeps the modifier held across repeated presses of the same SM key: first press sends Alt+Tab, second press sends Alt+Tab again (Alt still held), and Alt only releases when you press something else entirely. OSM is for one-shot use; SM is specifically for cycling (Alt+Tab, Ctrl+Tab) where you need the modifier to persist across multiple presses of the same key. |
Great, maybe the two types be merged into a single type of behavior, i.e. a general "Sticky Key"? I'm imaging some like
With this, one-shot mod can be represented as What do you think? |
I like that idea, since the two are conceptually similar. Let me think through what implementing this in the one-shot would need (and what I'd want from it). Max repeat, on the other hand, isn't something I'd personally use, though I see the utility. Right now I'm thinking of the browser tabs I have open. As long as we can set "no max repeat" or "infinite" as an option, that works for me. I'd also like a timeout that's independent of the one-shot timeout. This probably isn't strictly necessary, but I suspect most people who use this would want a different timeout than the one for one-shot keys. I usually exit a sticky mod with my layer button, but when cycling through browser tabs I'll sometimes go through several, pause to look at the screen, then continue. Again, a personal preference I could make work with a shared timeout, but worth mentioning. I also wonder how this interacts with layer changes. I use this key on another layer (via MO) so that returning to the base layer automatically exits the sticky mod and I can resume typing immediately. So I'd want an optional per-key feature to exit on layer change. I wouldn't want this on my other one-shot keys, though, since I use those across layers constantly; I added it specifically to this sticky mod implementation. Forgive the verbosity; talking through it helped. I'm happy to fold this into the current one-shot keys implementation, but if you want to go that route, I would prefer a per-key timeout option and a per-key "exit on layer change" option. Adding max retries is a great idea. I don't know what you'd want as the default, but I'd like to have the max retries configurable to have infinite, or to assume infinite until the timeout from the last sticky mod key press. What do you think? |
Yes, I agree. Omitting it means "infinite", i.e. About the per-key timeout, I have no strong opinion on it. Using the shared timeout as the default and overriding it when a per-key timeout exists is fine with me. But it does increase the complexity and RAM/Flash usage.
Should it be included in the keep list? Also, the length of keep list is a bit tricky. I think it should be calculated at compile-time and applied to the type. TBH I don't know if the idea works, but I think it's worth a try at least. |
|
Just a quick update, I think I'll be done with this by the end of the week or earlier for you to look at. I may also wait until #854 is done as well and make sure that I have it working with that merge. |
|
Great! Only a minor comment left in #854, I think we can get it merged first. |
|
#854 is merged |
…ion, and release triggers
- Restore pre-existing comments in LayerOff, LayerToggle, DefaultLayer arms
- Fix typo in LayerToggle comment ("release" → "released")
- Remove unused HidKeyCode import from sticky_mod.rs
…ase guard Action::Key(KeyCode::Hid(LShift)) etc. are modifier keys expressed via the Key action rather than the Modifier action. The SM release guard now checks hid_key.is_modifier() so that holding Shift for reverse-Tab cycling doesn't break StickyMod state.
Five rusty_fork_test cases covering: basic two-press flow, layer-change cleanup, Shift-does-not-release-SM, rapid triple presses, and combined LCtrl|LShift modifier.
rusty-fork was used by keyboard_sticky_mod_test but missing from Cargo.toml dev-dependencies. Also add missing .await on process_action_layer_switch call in test_key_action_transparent (function became async in upstream refactor).
…d StickyMod docs - Make DurationMillis pub (was pub(crate), caused visibility warning via StickyModConfig pub field) - Add test_sm_action_parsing and test_sm_action_grammar to rmk-config/src/layout.rs - Add Sticky Modifiers section to behavior.md - Add SM(key, modifier) entry to layout.md advanced layer operations list
Move timeout tracking out of the release handler's blocking select and into the main run() loop, following the same pattern as mouse repeat deadlines. - StickyModState::Active now stores an optional Instant deadline - Deadline is set (and reset) on each SM key PRESS, so repeated presses extend the hold window rather than starting from the release - run() combines SM and mouse deadlines and uses with_deadline(); on expiry it calls release_sticky_mod_if_active() before continuing - Remove embassy_futures select from release handler (was fragile: any event arriving cancelled the timer, preventing timeout on 2nd+ press) - Add sticky_mod_timeout() accessor to KeyMap - Add 2 integration tests: test_sm_timeout and test_sm_timeout_resets_on_press
…shots - Replace map_or(false, ...) with is_some_and(...) in keyboard.rs run() loop - Regenerate endpoint key snapshots in rmk-types: Action::StickyMod added a variant to the Action enum, changing the postcard schema hash for keymap, combo, and morse endpoints
|
Thanks for the feedback on consolidating SM and OSM! I've rebased this branch onto the latest What changed:
Example usage: |
…es not yet implemented) Tests cover: basic flow, layer-change cleanup, shift coexistence, rapid presses, combined modifiers, global timeout, timeout reset, max_repeat, per-key timeout, exit_on_layer_change=true, and exit_on_layer_change=false (survives layer change). Compile fails on StickyKeyConfig, StickyKeyAction, sk! macro, and BehaviorConfig::sticky_key — all to be added in Tasks 3–8.
…rd D2 (test compile deferred to Stage 2)
…eadline (DP-1, DP-2)
…e 1 sentinel defect)
…quick_release, terminating-key application (3a/3b/3c) Branch on StickyKeyAction shape in process_action_sticky_key: - pure-mod (key==No, layer==None): port OSM state machine onto the unified latch (Pressed/Latched/Held), accumulate mods across taps, honor activate_on_keypress and quick_release from StickyKeyConfig, apply the mod through the terminating key via resolve_explicit_modifiers + a new update_sticky_key foreign-key hook. - tap-key parity preserved on the same latch (alt-tab cycling, max_repeat), timeout driven solely by the run-loop deadline race. The five exit_on_layer_change call sites now read sticky_key_config().release_on_layer_change; keymap exposes sticky_key_config(). Pure-mod SKs are no longer released before a foreign key. release_sticky_key_if_active suppresses the spurious empty report when a bare Latched pure-mod times out. Adds regression tests test_sk_puremod_terminating_key (3b) and test_sk_puremod_cross_tap_accumulation (3c).
…rocessed_events producers (keep queue for Clear Peer) Delete process_action_osm and update_osm from oneshot.rs (incl. the inline select timeout and the OSM unprocessed_events push/retain). OSL functions (process_action_osl, update_osl) and OneShotState stay for Stage 3; OSL's inline select now sources the shared sticky_key_timeout. The unprocessed_events queue + run-loop consumer are retained for the Clear Peer BLE path; an explanatory comment was added near the consumer. Migrate keyboard_combo_test.rs off the removed osm! macro / OneShotConfig / OneShotModifiersConfig to sk_mod! / StickyKeyConfig.
…t parity); document single-latch shape assumption
…iew-fix adjudication
…ombination behavior on single latch
…difier OSL non-consume (Stage 3 review fixes)
…iew-fix adjudication
…magic-field rule (Section 6) - appendix.md: include all 5 sticky_key fields in the full-config example - behavior.md: precise magic-field rationale (layer SK sends no modifier) - behavior.md: correct activate_on_keypress description (fires on the SK press, not the next key) - behavior.md: fix pre-existing dead anchor on the rewritten keymap cross-link
Update the two rmk-config SK tests (test_sk_action_parsing, test_sk_action_grammar) that still asserted the removed 5-positional SK tail. They now cover all three SK shapes: tap-key SK(key, [mods]), pure-mod one-shot modifier SK(LGui), and one-shot layer SK(MO(n)), including case-insensitive variants. Also apply cargo fmt to sticky_key.rs and the two SK test files, which were committed unformatted and broke the format CI job.
…codegen Mixed-shape SK-on-SK latch (item HaoboGu#1): a press of a different-shape sticky key now releases the active latch via release_sticky_key_if_active() before creating the new one, so only a same-shape latch reaches the accumulate (pure-mod) or cycle (tap-key) arm. Fixes an orphaned held layer and silent mod-merge when, e.g., a tap-key SK followed a pure-mod or layer SK. Add KEYMAP_MIXED + 3 regression tests (tap-key replaces pure-mod, pure-mod replaces tap-key, tap-key replaces layer). 40/40 sticky-key + one-shot pass. Nits (item HaoboGu#4): - repeat_count uses saturating_add so an unbounded (max_repeat==0) cycle cannot overflow and panic in debug after 65535 presses. - action_parser emits a targeted error for non-MO nested layer forms like SK(TG(1)) instead of the misleading "not a modifier" panic.
Evaluation of removing OneShotModifier/OneShotLayer Action variants and the 0x5280-0x52BF Vial keycodes. Conclusion: safe zero-code migration. Storage serializes KeyAction via postcard (discriminant-based), but BUILD_HASH (crc32 of commit+timestamp) changes every build, so upgrading always erases and reinitializes flash from compiled-in defaults -- new firmware never reads old-layout bytes. from_via_keycode degrades stale keycodes to KeyAction::No (no panic); OneShotTimeout setting variant name kept for storage stability.
These docs/superpowers/ design specs, plans, and the migration-findings note were internal working artifacts for the OSM->StickyKey merge. They are archived outside the repo and should not ship in the upstream PR. CLAUDE.md is intentionally left in place (it is existing upstream content).
# Conflicts: # docs/docs/main/docs/configuration/behavior.md
The SK consolidation removed OSM()/OSL() keymap actions; the in-repo example configs still used them, breaking every use_config build. Migrate per the documented mapping: OSL(n)->SK(MO(n)), OSM(mod)->SK(mod).
expand_sticky_key emits a Duration unconditionally (every keyboard gets a default sticky_key config), so the bare ::embassy_time::Duration path forced every consumer to have embassy-time as a direct dependency. The 4 ESP use_config examples don't, so they failed with E0433. Use the ::rmk-re-exported path (as watchdog.rs already does), which resolves for any crate depending on rmk regardless of a direct embassy-time dep.
…n tap-key SKs The docs state these two knobs are honored only for pure-mod SKs and silently ignored for tap-key SKs, but no test covered that negative guarantee. Add two tests that run the canonical tap-key flow with each flag enabled and assert the report stream is identical to the default-config flow — proving the flags have no effect on tap-key behavior.
|
@HaoboGu what do you think? Claude's summary of all the changes below: Summary: unify One-Shot (OSM/OSL) and Sticky Key into a single
|
| Shape | Syntax | Behavior |
|---|---|---|
| Pure-mod | SK(LGui), SK(LCtrl|LShift) |
One-shot modifier — held for the next key press, then auto-released (former OSM). |
| Layer | SK(MO(n)) |
One-shot layer — layer n active for the next key press, then released (former OSL). |
| Tap-key | SK(Tab, [LAlt]), SK(Tab, [LCtrl|LShift]) |
Modifier held across repeated presses of the same key (Alt+Tab–style cycling); releases on any other key. |
|
Unified config: [behavior.sticky_key]
A single table replaces [behavior.one_shot] and [behavior.one_shot_modifiers]:
| Field | Default | Notes |
|---|---|---|
timeout |
1s |
Idle auto-release; applies to all shapes. |
activate_on_keypress |
false |
Pure-mod only (OSSM) — send modifier on the SK press itself. |
quick_release |
false |
Pure-mod only — release modifier on next key press vs release. |
max_repeat |
0 |
Tap-key cycling cap; 0 = unlimited. |
release_on_layer_change |
false |
Whether a layer change releases the SK. |
(activate_on_keypress/quick_release are pure-mod–only and silently ignored for tap-key/layer shapes.)
Breaking changes / migration
OSM(mod)→SK(mod),OSL(n)→SK(MO(n))— old actions now error at build time.[behavior.one_shot]/[behavior.one_shot_modifiers]tables →[behavior.sticky_key].- Old 5-positional
SK(key, [mod], max_repeat, timeout, exit_on_layer_change)→SK(key, [mod])+ the config table. exit_on_layer_change(per-key) →release_on_layer_change(global).- Tap-key SKs now have a 1s default timeout (previously none).
A full migration table is in docs/configuration/behavior.md, which has been rewritten to document the shapes, every config field, and the migration.
Tests
keyboard_sticky_key_test.rs— 17 tests covering all three shapes, mixed-shape replacement, timeout/reset,max_repeat,release_on_layer_changesurvive/exit, and that
the pure-mod–only flags (activate_on_keypress/quick_release) are correctly ignored on tap-key SKs.keyboard_one_shot_test.rs— pure-mod path through the new engine, coveringactivate_on_keypressandquick_release(chain + quick-release modes, combined modifiers).- All five
[behavior.sticky_key]fields are exercised; the in-repouse_configexamples are migrated to the new syntax.
Internals
Wire format, storage, and Vial keycode handling updated for the unified action; the removed OSM/OSL keycodes degrade gracefully to No rather than panicking.
|
Sorry for the delay, I'm quite busy these days. A few comments/questions:
|
@HaoboGu, No problem, I'm in the same boat, way too much to do, far too little time.
The mechanism is slightly different from what you described. max_repeat is a single global value (default 0), and it's read in exactly one place — the tap-key path: ┌───────────────────────────┬───────────────────┬──────────────────────────────────────────────────────────┐
│ Shape │ Reads max_repeat? │ Behavior │
├───────────────────────────┼───────────────────┼──────────────────────────────────────────────────────────┤
│ Tap-key (SK(Tab, [LAlt])) │ Yes │ 0 (default) = unlimited; N > 0 = release after N presses │
├───────────────────────────┼───────────────────┼──────────────────────────────────────────────────────────┤
│ Pure-mod (SK(LGui) / OSM) │ No │ Always applies to exactly one following key (hardcoded) │
├───────────────────────────┼───────────────────┼──────────────────────────────────────────────────────────┤
│ Layer (SK(MO(n)) / OSL) │ No │ Always applies to exactly one following key (hardcoded) │
└───────────────────────────┴───────────────────┴──────────────────────────────────────────────────────────┘So the value isn't auto-set to 1 for one-shots and 0 for tap-key — it's one global that defaults to 0, and the one-shot shapes simply never read it (their "one key only" behavior is baked into their code path). I'll document this and fix the current max_repeat row in behavior.md, which misleadingly implies it applies to one-shot modifiers. Proposed wording: max_repeat — Tap-key SKs only. Caps how many repeated presses keep the modifier held; 0 (default) = unlimited. Pure-mod (SK(LGui)) and layer (SK(MO(n))) SKs ignore this — they always apply to exactly one following key. I can't think of a way that Max Repeat would be useful for anything other than the sticky mod behavior. But it's basically essential for the sticky mod behavior to have more than one (and ideally many/infante max repeat). If you can think of a use for using Max Repeat on other one shot keys, I can add that, that's fine, but I just don't think it is needed. Again, feel free to fill me in on a use that you think this will be used for and I'm more than willing to add it, but then I think we will want to have separate max repeats for those two different behaviors.
My bad, I'll add them back as aliases so it'll all use as much of the same code as possible.
These are one-shot-modifier semantics, so they only apply to the pure-mod shape (OSM, equivalently SK(mod)):
I'll make sure this is clearly documented alongside the fields. Do those changes sound good (I'll get working on them and I'll probably be done with them soon). |
Per HaoboGu's 2026-06-16 review of HaoboGu#859: keep OSM(mod) and OSL(n) in user space while internals stay unified on the sticky-key engine. OSM/OSL desugar at the codegen layer (parse_key), not in keymap_parser, so they work everywhere SK works -- the keymap grid AND encoders (which bypass keymap_parser and only run alias resolution). They emit the exact same sk_mod!/sk_layer! tokens as SK(mod)/SK(MO(n)), so the produced actions are byte-identical. OSL is numeric-only, matching SK(MO(n)). - keymap.pest: re-add osm_action/osl_action rules; kept out of layer_action so SK(OSL(n)) stays a grammar error - layout.rs: keymap_parser forwards them as-is (like sk_action) + tests - action_parser.rs: osl( -> sk_layer!, osm( -> sk_mod! arms + a token equivalence test (osm_osl_aliases_match_sk_tokens) Also two doc-only fixes from the same review: - behavior.md: max_repeat clarified as tap-key-only (pure-mod/layer SKs ignore it -- always exactly one following key) - behavior.md/layout.md: describe OSM/OSL as aliases, not removed; note activate_on_keypress/quick_release are pure-mod-only
|
Yes it looks good, thanks for your effort! |
Absorb 33 upstream commits (parser refactor, nested actions in MT/TH/LT slots, pointing-mode input-device work, docs). Two conflicts resolved with StickyKey taking precedence over the original one-shot system everywhere they overlap: - rmk-config/src/layout.rs: take upstream's catch-all keymap_parser (_ => resolve_layer_names); append our SK + OSM/OSL alias parse tests. - rmk-macro/src/codegen/action_parser.rs: keep upstream's refactored parse_action/parse_key helpers; drop its osm()->OneShotModifier and osl()->OneShotLayer branches; re-add our osl()/osm()/sk() branches that desugar to sk_layer!/sk_mod!/sk!; fix the OSM(...) test assertion to expect sk_mod! tokens. OSM/OSL remain user-facing aliases for StickyKey; nothing returns OneShot* actions. Local CI green: test.sh (all crates + rmk 7-featureset matrix + doctests), format.sh, clippy -D warnings.
Summary
Adds a new
SM(key, modifier)action — StickyMod — that holds a modifieracross repeated presses of the same key, then releases it automatically when
any non-SM, non-modifier key is pressed, the active layer changes, or an
optional timeout expires.
Primary use case: Alt+Tab window/tab cycling. Bind
SM(Tab, LAlt)to akey; the first press sends Alt+Tab, subsequent presses send Tab (Alt stays
held), and Alt releases as soon as you press any other key or switch layers.
Motivation
This is a direct port of the
KC.SK()(Sticky Key) behavior from KMK firmware.For those migrating from KMK,
SM(Tab, LAlt)replicatesKC.SK(KC.LALT)usedin conjunction with Tab for Alt+Tab cycling. This was the last regularly-used
KMK feature I needed in order to fully replicate my KMK keymap in RMK
(though others may find additional gaps).
This feature covers similar ground to #724 (Tabber), which I was aware of
before writing this implementation but found didn't quite fit my needs — I
preferred this approach because it generalizes to any key+modifier combination
rather than being Tab-specific, and includes timeout support. That said, I
have no attachment to the name
SMorStickyMod— happy to rename this towhatever fits best if this is merged.
How it differs from OSM
OSM(mod)SM(key, mod)Behavior details
modifier + keykeyagain; timeout resetsModifieractions and HID modifier keycodes (Shift, Ctrl, etc.)do not release SM — this lets Shift+Tab work for reverse cycling
Optional timeout
Timeout is measured from the last SM press — repeated presses extend the
hold window. Implemented via the main
run()loop deadline (same pattern asmouse repeat), so it fires reliably regardless of how many press/release
cycles have occurred.
Default: no timeout (modifier held until released by keypress or layer change).
TOML syntax
Changes
rmk-types: newAction::StickyMod(KeyCode, ModifierCombination)variantrmk: newkeyboard/sticky_mod.rsmodule —StickyModStatewith optional deadline, state machine, and processing logicrmk: integrated intokeyboard.rs— dispatch, modifier resolution, release triggers on layer deactivation, deadline-based timeout inrun()looprmk-config: TOML grammar (keymap.pest) and parser forSM(key, mod)syntaxrmk-macro: codegen support forSMinaction_parser.rsandbehavior.rsrmk:sm!()macro inlayout_macro.rsdocs:behavior.md(Sticky Modifiers section),layout.md(SM syntax entry)Tests
7 integration tests in
rmk/tests/keyboard_sticky_mod_test.rs:LCtrl|LShiftcombination