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 33 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.
…out, and exit_on_layer_change
When a non-SK key press triggers SK release, the implementation emits two separate HID reports: first the SK release (modifier cleared), then the new key registration. Update the test to expect both reports rather than a single combined report.
…d SK PEG grammar - Rename StickyModConfig → StickyKeyConfig in rmk-config/src/lib.rs - Rename BehaviorConfig.sticky_mod → sticky_key field - Remove sm_action PEG rule; add boolean, modifier_keep_list, sk_action rules - Replace Rule::sm_action arm with Rule::sk_action in layout.rs - Replace SM tests with SK grammar/parsing tests - Rename sticky_mod_timeout_ms → sticky_key_timeout_ms in resolved/behavior.rs - Fix cross-crate breakage in rmk-macro: update field reference to sticky_key_timeout_ms
…opts) syntax - action_parser.rs: replace SM( arm with SK( arm; parses SK(key,[keep_mods],max_repeat,timeout_ms,exit_on_layer_change) using bracket-delimited keep-mod list and optional trailing args; emits ::rmk::sk!() - behavior.rs: rename expand_sticky_mod→expand_sticky_key, use StickyKeyConfig, update field to sticky_key: in BehaviorConfig quote block; add sticky_mod: StickyModConfig::default() for remaining field
…ently; simplify repeat check
…ere never updated after SM→SK migration
…ire-format snapshots Snapshots changed because SK adds fields to the behavior wire type, shifting endpoint key hashes.
… nightly rustfmt - endpoint_keys_bulk.snap: the committed bytes were generated under a non-host BULK_SIZE; CI runs rmk-types with --features host, where BULK_SIZE = MAX_BULK_SIZE = 16, so the postcard schema hashes for the bulk_get RESP / bulk_set REQ endpoints differed. Regenerated with --features host to match CI. - Apply nightly rustfmt to sticky_key.rs, layout_macro.rs, action_parser.rs, and keyboard_sticky_key_test.rs (CI uses nightly).
|
I was imaging |
I'm glad you brought this up. So as I mentioned earlier, I'm relying heavily on Claude, and so I clearly did not quite define well enough what I wanted it to do. Also, I somehow forgot that you were okay with completely replacing the one shot with sticky key, and so I was aiming to be completely backwards compatible. But re reading our earlier conversation, I realized that is not exactly what you wanted. I decided to merge in what I already had anyway because there were some changes that I thought were worth it to get into this branch and PR, and then I can now take another step of completely eliminating the one shot and migrating all those features into the sticky keys. So I do still have a couple of questions for you. Mostly it's about how we want this formatted. I did make a couple of changes based on your recommendations from earlier, but you didn't quite cover everything in terms of how they should look for the new sticky key only migration. Below are examples of everything we'll need (I think). old: OSM(LGui)
new: SK(LGui)
old: OSL(1)
new: SK(1) or SK(MO(1)) or SK(TO(1)) or SK("L1") or SK(Layer = 1) or SK(Layer, [1])
old: SK(Tab, [LAlt], 0, 0, true) or SK(Tab, [LCtrl])
new: SK(Tab, [LAlt])So the OSM is very straightforward I assume no issues there though you had previously suggested For OSL There are many different options here. I'm not really sure which one you'd prefer to take. If we just use SK(1) that'll be a very straightforward translation. However, I'm not sure that this is the way I would prefer. I think I prefer one of these two: Finally, for the sticky mod I think just simplifying from your suggested Which brings us to the other major area where I would like your opinion. As of right now (for the most part) we have three Toml profiles that handle the three different sets of one shot/sticky keys. Do note that the [behavior.sticky_key] options are implemented on the key definition itself but I will change that to just use the toml profile instead (more on this below). old: [behavior.one_shot]
timeout = "1s" # default: 1s (Duration::from_secs(1))
[behavior.one_shot_modifiers]
activate_on_keypress = false # default: false
quick_release = false # default: false
[behavior.sticky_key]
timeout = "5s" # default: no timeout
max_repeat = 0 # default: 0 = infinite (fires key on presses 1..=max_repeat, releases on max_repeat+1)
exit_on_layer_change = false # default: false = SK survives layer changes (release only on key/timeout)new: [behavior.sticky_key]
timeout = "1s" # default: 1s (Duration::from_secs(1))
activate_on_keypress = false # default: false
quick_release = false # default: false
max_repeat = 0 # default: 0 = infinite (fires key on presses 1..=max_repeat, releases on max_repeat+1)
exit_on_layer_change = false # default: false = SK survives layer changes (release only on key/timeout)So I'm fairly certain that the behavior.one_shot and the behavior.one_shot_modifiers can easily be combined and just move the timeout into the behavior one shot modifiers and apply that default timeout to the one shot modifiers. If you have any issues with that, let me know. Now the behavior.sticky_key is a bit more complicated. I don't see any large issue with timeout being combined and applied to all one-shot/sticky keys, especially if down the road I implement an individual key override (which I do want so that I can increase the timeout of the alt tab sticky key/mod). The max repeat can be set there and does not interfere with the current one shot modifiers, but will only apply to the current sticky keys, so as I see it no issue combining there. Now the exit_on_layer_change could be more complicated. I could see some people not wanting a one-shot modifier to be held across a layer change. However, I would personally prefer that, but for my sticky mods, I do not want that. So once again, I would like to see an individual key override for this, but, I do not think it's a bad idea to keep this as a default that you set in the toml profile. Also, I'm not sure I actually like exit_on_layer_change as the name for this parameter. So if you have other suggestions for what to change this name to, let me know. For now I don't think I'm going to implement any key level overrides to rather keep the combining as simple as possible. Then once I've confirmed that the consolidation is working well, then I would like to see what kind of effect adding these individual key overrides has on the RAM and flash. What do you think? |
|
Thanks for your detailed explanation! Yes some details needs to be clarified.
Thanks again for your effort! Please feel free to discuss anything you're unclear about. |
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