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 92 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.
Per HaoboGu's review of HaoboGu#859: consolidate the five flat sticky_key_timeout_ms/activate_on_keypress/quick_release/max_repeat/ release_on_layer_change fields on Behavior into a StickyKeyConfig struct, matching the runtime StickyKeyConfig already in rmk/src/config/behavior.rs.
Per HaoboGu's review of HaoboGu#859: keep OSM/OSL in user space by adding thin macro aliases that delegate to the SK backend macros. - osm!(M) = sk_mod!(M) - osl!(n) = sk_layer!(n)
Per HaoboGu's review of HaoboGu#859: rusty-fork is not needed with cargo-nextest which handles test isolation natively. Removed the dep, the import, and the rusty_fork_test! wrapper from keyboard_sticky_key_test.rs.
…n pure-mod release Item 5: release_sticky_key_if_active() now skips if phase is Pressed (the key is still physically held) — defers cleanup to the physical release handler so the modifier doesn't disappear mid-hold. Item 6: pure-mod deadline refreshed on release-to-Latched, matching existing layer behavior. Previously the deadline was set on press and never updated, so holding OSM for 5s then releasing caused a near- immediate timeout.
…n pure-mod release Item 5: release_sticky_key_if_active() now skips if phase is Pressed (the key is still physically held) and clears the deadline to avoid busy-looping — defers cleanup to the physical release handler so the modifier doesn't disappear mid-hold. Item 6: pure-mod deadline refreshed on release-to-Latched, matching existing layer behavior. Previously the deadline was set on press and never updated, so holding OSM for 5s then releasing caused a near- immediate timeout.
…while-held
Fix A: Add trailing newline to keyboard_sticky_key_test.rs
Fix B: Restructure with_deadline to only wrap subscriber wait, not
process_inner, eliminating spurious timeout risk during processing
Fix C: Add test_sk_timeout_while_held — exercises the Item 5 guard
(timeout fires while SK physically held in Pressed phase)
|
Okay I think I got it all. I also did some testing on my own keyboard and everything worked as intended except for "stacking" OSM and OSL I'm still working on that right now. All 9 review items from HaoboGu's Jun 27-29 review on commit ebfe286 are resolved. Changes are on branch
Post-review fixes (self-discovered during code review)
|
…od and layer behavior The previous single-latch design forced mutual exclusion: pressing a layer SK (OSL) while a pure-mod SK (OSM) was latched would overwrite the state, dropping the modifier. The old parallel OSM/OSL state machines allowed them to coexist — a common QMK one-shot workflow. Fix allows pure-mod (modifiers) and layer (OSL) shapes to compose: they affect different dimensions and are naturally compatible. Tap-key remains exclusive with both. Changes: - process_sticky_pure_mod: skip release guard when active state is a layer (compatible) - process_sticky_layer: capture existing_mods from prior state and carry them forward when overwriting - update_sticky_key: new quick_release arm deactivates layer before clearing state when both are active - resolve_explicit_modifiers: treat layer shapes same as pure-mod (only add modifiers on press/Held, not release) - test: updated osm_then_osl/osl_then_osm expectations to reflect coexistence behavior
|
Okay I think I got the OSL and OSM "stacking" working. I've manually confirmed that any combination of tapping and/or holding either of those in any sequence worked as intended (brief summary of changes below). OSM + OSL Coexistence (commit 231df5c) The single-latch design forced mutual exclusion between OSM and OSL. Fixed by allowing pure-mod (modifiers) and layer shapes to compose — they affect different dimensions and are naturally compatible. Tap-key remains exclusive with both. Changes:
|
|
I just checked, some comments are still unresolved? |
You're right, my bad, I'm working on resolving the last few and I'll post another comment when I think I got all of them. Thanks for your patience! |
The previous commit (5ac8d57: feat(sticky-key): restore OSM/OSL as aliases) only added the macro-level aliases but did NOT touch the Vial keycode conversion layer. This completes Item 2 of HaoboGu's review: the Vial keycode_convert.rs now properly maps: to_via_keycode: Action::StickyKey → 0x52A0|bits (OSM) / 0x5280|l (OSL) from_via_keycode: 0x52A0..0x52BF → Action::StickyKey (OSM) 0x5280..0x529F → Action::StickyKey (OSL) Uses VIA-defined ranges 0x5280/0x52A0 (same as old OneShotLayer/ OneShotModifier) rather than QMK's 0x7C02/0x7C03 ranges, because VIA ranges avoid prefix-vs-payload bit overlap and round-trip cleanly through packed bits. Round-trip tests added for OSM(LCtrl), OSM(LShift), OSM(LAlt), OSL(0), and OSL(5). Per HaoboGu: 'Keep Vial OSM/OSL supported, just migrate to SK backend' 522/522 tests pass.
…avoid stale-read race The shared captured at loop iteration start can go stale when the first async post-check (release_sticky_key_if_active) sends HID reports and takes real time. The second post-check (fire_mouse_repeat) then compares against a stale timestamp, potentially missing its deadline by one iteration. Using inline Instant::now() in each closure ensures every deadline check uses current wall-clock time. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
# Conflicts: # rmk-config/src/lib.rs # rmk-config/src/resolved/behavior.rs # rmk-macro/src/codegen/behavior.rs # rmk/src/config/behavior.rs # rmk/src/config/mod.rs
|
Okay I think that is everything Not sure why that one test failed it says what I copied below so just rerunning it should work. [warn]: Failed to get latest Xtensa Rust version: HTTP GET Error: GitHub API returned status code: 403 Forbidden Let me know if I missed something again. |
|
That's because the unstable network? of the github action server I think. Reran it. |
| self.keymap.deactivate_layer(layer_num); | ||
| } | ||
|
|
||
| self.sticky_key_state = StickyKeyState::None; |
|
|
||
| let mut should_deactivate = false; | ||
|
|
||
| match &mut self.sticky_key_state { |
There was a problem hiding this comment.
If there are 2 stickykeys are pressed, the second key's state is wrong.
There was a problem hiding this comment.
Fixed in a05a9942.
StickyKey state now tracks the owning physical key, so a second tap-key StickyKey replaces the first cleanly and the first key’s later release does not affect the second.
Addresses HaoboGu's review comments from PR HaoboGu#859: - Restore CI artifact upload steps accidentally deleted during merge conflict resolution (comment 1) - Remove personal VS Code theme customizations accidentally committed (comment 2)
# Conflicts: # rmk/tests/keyboard_combo_test.rs
|
Resolved in 301c6352. Merged the latest upstream changes and resolved the combo-test conflict by retaining the upstream combo regressions while migrating the one-shot test setup to StickyKey. |
|
The overall implementation is nice, but I'm worried about the increased FLASH/RAM. |
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