Skip to content

feat: add StickyMod (SM) action for Alt+Tab-style modifier cycling#859

Open
ldsands wants to merge 72 commits into
HaoboGu:mainfrom
ldsands:feat/sticky-mod
Open

feat: add StickyMod (SM) action for Alt+Tab-style modifier cycling#859
ldsands wants to merge 72 commits into
HaoboGu:mainfrom
ldsands:feat/sticky-mod

Conversation

@ldsands

@ldsands ldsands commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new SM(key, modifier) action — StickyMod — that holds a modifier
across 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 a
key; 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) replicates KC.SK(KC.LALT) used
in 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 SM or StickyMod — happy to rename this to
whatever fits best if this is merged.

How it differs from OSM

Behavior OSM(mod) SM(key, mod)
Modifier release After the next single keypress After any non-SM/non-modifier press, or layer change, or timeout
Key bundled No — modifier only Yes — modifier + key in one action
Repeatable cycling No Yes

Behavior details

  • First press: activates SM state, sends modifier + key
  • Release: unregisters key, modifier stays held
  • Subsequent presses: modifier already active, sends key again; timeout resets
  • Release triggers: any non-SM, non-modifier keypress; any layer deactivation; timeout expiry
  • Modifier exclusion: Modifier actions 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 as
mouse repeat), so it fires reliably regardless of how many press/release
cycles have occurred.

[behavior.sticky_mod]
timeout = "5s"   # auto-release modifier after 5s since last SM press

Default: no timeout (modifier held until released by keypress or layer change).

TOML syntax

SM(Tab, LAlt)           # Alt+Tab cycling
SM(Tab, LCtrl)          # Ctrl+Tab cycling
SM(Tab, LCtrl|LShift)   # Ctrl+Shift+Tab (reverse cycling)

Changes

  • rmk-types: new Action::StickyMod(KeyCode, ModifierCombination) variant
  • rmk: new keyboard/sticky_mod.rs module — StickyModState with optional deadline, state machine, and processing logic
  • rmk: integrated into keyboard.rs — dispatch, modifier resolution, release triggers on layer deactivation, deadline-based timeout in run() loop
  • rmk-config: TOML grammar (keymap.pest) and parser for SM(key, mod) syntax
  • rmk-macro: codegen support for SM in action_parser.rs and behavior.rs
  • rmk: sm!() macro in layout_macro.rs
  • docs: behavior.md (Sticky Modifiers section), layout.md (SM syntax entry)

Tests

7 integration tests in rmk/tests/keyboard_sticky_mod_test.rs:

  1. Basic flow: press SM twice while MO held
  2. Layer change cleanup: MO release triggers SM release
  3. Shift integration: Shift key does not release SM (enables reverse cycling)
  4. Rapid presses: 3× SM press/release cycle
  5. Combined modifiers: LCtrl|LShift combination
  6. Timeout: modifier auto-releases after inactivity
  7. Timeout reset: pressing SM again extends the timeout window

@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown

Size Report

Example main PR Diff .text .data .bss
use_config/nrf52832_ble 372.2 KiB 375.9 KiB +0.98% ⬆️ +2796 0 +960
use_config/nrf52840_ble 422.1 KiB 428.0 KiB +1.40% ⬆️ +2064 0 +3992
use_config/nrf52840_ble_split (central) 497.5 KiB 499.5 KiB +0.39% ⬆️ +1088 0 +912
use_config/nrf52840_ble_split (peripheral) 322.9 KiB 323.6 KiB +0.22% ⬆️ +744 0 0
use_config/pi_pico_w_ble 659.3 KiB 662.5 KiB +0.49% ⬆️ +2408 0 +912
use_config/rp2040 147.2 KiB 150.3 KiB +2.11% ⬆️ +2232 0 +960
use_config/rp2040_split (central) 160.8 KiB 163.9 KiB +1.96% ⬆️ +2280 0 +960
use_config/rp2040_split (peripheral) 27.8 KiB 27.8 KiB -0.01% ⬇️ -4 0 0
use_config/stm32f1 62.6 KiB 64.6 KiB +3.18% ⬆️ +1140 0 +904
use_config/stm32h7 99.8 KiB 102.1 KiB +2.34% ⬆️ +1452 0 +944
use_rust/nrf52832_ble 359.6 KiB 362.2 KiB +0.72% ⬆️ +1716 0 +960
use_rust/nrf52840_ble 418.1 KiB 422.5 KiB +1.05% ⬆️ +1340 0 +3184
use_rust/nrf52840_ble_split (central) 506.5 KiB 510.1 KiB +0.72% ⬆️ +1968 0 +1776
use_rust/nrf52840_ble_split (peripheral) 319.6 KiB 320.2 KiB +0.18% ⬆️ +620 0 0
use_rust/pi_pico_w_ble 659.9 KiB 663.2 KiB +0.49% ⬆️ +2432 0 +912
use_rust/rp2040 147.2 KiB 150.2 KiB +2.00% ⬆️ +2060 0 +960
use_rust/rp2040_split (central) 160.0 KiB 162.9 KiB +1.86% ⬆️ +2092 0 +960
use_rust/rp2040_split (peripheral) 28.1 KiB 28.1 KiB -0.01% ⬇️ -4 0 0
use_rust/stm32f1 62.6 KiB 64.1 KiB +2.33% ⬆️ +596 0 +904
use_rust/stm32h7 121.2 KiB 124.3 KiB +2.59% ⬆️ +2264 0 +960
use_config/nrf52832_ble — 372.2 KiB → 375.9 KiB (+0.98% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 344248	   5256	  35368	 384872	  5df68	rmk-nrf52832

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 341452	   5256	  34408	 381116	  5d0bc	rmk-nrf52832

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.4% +13.0Ki  [ = ]       0    .debug_str
  +0.3% +6.40Ki  [ = ]       0    .debug_info
  +0.9% +2.60Ki  +0.9% +2.60Ki    .text
  +0.2% +1.21Ki  [ = ]       0    .debug_loc
  +0.6% +1.15Ki  [ = ]       0    .debug_ranges
  [ = ]       0  +2.9%    +960    .bss
  +0.3%    +799  [ = ]       0    .debug_line
  +0.3%    +132  +0.3%    +132    .rodata
  +0.0%      +4  [ = ]       0    .debug_frame
  +0.1%      +1  [ = ]       0    .defmt
 -18.0%     -11  [ = ]       0    [Unmapped]
  -0.0%     -16  [ = ]       0    .debug_aranges
  -0.3%     -23  [ = ]       0    .debug_abbrev
  -0.1%     -64  [ = ]       0    .symtab
  -0.1%    -189  [ = ]       0    .strtab
  +0.4% +25.0Ki  +1.0% +3.67Ki    TOTAL
use_config/nrf52840_ble — 422.1 KiB → 428.0 KiB (+1.40% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 377780	   5264	  55208	 438252	  6afec	rmk-nrf52840

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 375716	   5264	  51216	 432196	  69844	rmk-nrf52840

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.4% +14.0Ki  [ = ]       0    .debug_str
  +0.4% +8.94Ki  [ = ]       0    .debug_info
  [ = ]       0  +8.0% +3.90Ki    .bss
  +0.6% +1.89Ki  +0.6% +1.89Ki    .text
  +0.2%    +640  [ = ]       0    .debug_line
  +0.3%    +352  [ = ]       0    .symtab
  +0.1%    +264  [ = ]       0    .debug_ranges
  +0.4%    +208  [ = ]       0    .debug_frame
  +0.1%    +162  [ = ]       0    .strtab
  +0.3%    +124  +0.3%    +124    .rodata
  +0.2%     +88  [ = ]       0    .debug_aranges
  +0.1%      +1  [ = ]       0    .defmt
  -0.0%      -5  [ = ]       0    .debug_loc
 -32.8%     -21  [ = ]       0    [Unmapped]
  -0.6%     -52  [ = ]       0    .debug_abbrev
  +0.3% +26.5Ki  +1.4% +5.91Ki    TOTAL
use_config/nrf52840_ble_split (central) — 497.5 KiB → 499.5 KiB (+0.39% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 456948	   6588	  47944	 511480	  7cdf8	central

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 455860	   6588	  47032	 509480	  7c628	central

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.3% +11.4Ki  [ = ]       0    .debug_str
  +0.3% +7.35Ki  [ = ]       0    .debug_info
  +0.2%    +972  +0.2%    +972    .text
  [ = ]       0  +2.0%    +912    .bss
  +0.1%    +555  [ = ]       0    .debug_line
  +0.2%    +472  [ = ]       0    .debug_ranges
  +0.3%    +116  +0.3%    +116    .rodata
  +0.1%      +1  [ = ]       0    .defmt
  -3.1%      -2  [ = ]       0    [Unmapped]
  -0.1%     -40  [ = ]       0    .debug_aranges
  -0.7%     -57  [ = ]       0    .debug_abbrev
  -0.0%     -76  [ = ]       0    .debug_loc
  -0.2%     -96  [ = ]       0    .debug_frame
  -0.1%    -176  [ = ]       0    .symtab
  -0.1%    -427  [ = ]       0    .strtab
  +0.2% +19.9Ki  +0.4% +1.95Ki    TOTAL
use_config/nrf52840_ble_split (peripheral) — 322.9 KiB → 323.6 KiB (+0.22% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 297900	   5920	  27592	 331412	  50e94	peripheral

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 297156	   5920	  27592	 330668	  50bac	peripheral

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.1% +3.66Ki  [ = ]       0    .debug_str
  +0.1% +2.00Ki  [ = ]       0    .debug_info
  +0.3%    +744  +0.3%    +744    .text
  +0.2%    +240  [ = ]       0    .symtab
  +0.1%    +220  [ = ]       0    .strtab
  +0.3%     +96  [ = ]       0    .debug_frame
  +0.1%     +56  [ = ]       0    .debug_aranges
  +0.2%      +1  [ = ]       0    .defmt
  -0.0%      -2  [ = ]       0    .debug_abbrev
 -16.3%      -7  [ = ]       0    [Unmapped]
  -0.2%    -384  [ = ]       0    .debug_line
  -0.3%    -472  [ = ]       0    .debug_ranges
  -0.2% -1.29Ki  [ = ]       0    .debug_loc
  +0.1% +4.85Ki  +0.2%    +744    TOTAL
use_config/pi_pico_w_ble — 659.3 KiB → 662.5 KiB (+0.49% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 621908	      0	  56516	 678424	  a5a18	rmk-pi-pico-w

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 619500	      0	  55604	 675104	  a4d20	rmk-pi-pico-w

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.3% +10.8Ki  [ = ]       0    .debug_str
  +0.4% +8.61Ki  [ = ]       0    .debug_info
  +0.7% +2.23Ki  +0.7% +2.23Ki    .text
  +0.9% +2.19Ki  [ = ]       0    .debug_ranges
  +0.4% +1.66Ki  [ = ]       0    .debug_line
  [ = ]       0  +1.7%    +912    .bss
  +0.4%    +320  [ = ]       0    .symtab
  +0.0%    +124  +0.0%    +124    .rodata
  +0.1%     +40  [ = ]       0    .debug_frame
  +0.0%      +8  [ = ]       0    .debug_aranges
  +0.1%      +1  [ = ]       0    .defmt
  -8.8%      -6  [ = ]       0    [Unmapped]
  -0.0%     -46  [ = ]       0    .strtab
  -0.2% -2.18Ki  [ = ]       0    .debug_loc
  +0.3% +23.8Ki  +0.5% +3.24Ki    TOTAL
use_config/rp2040 — 147.2 KiB → 150.3 KiB (+2.11% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 137564	      0	  16332	 153896	  25928	rmk-rp2040

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 135332	      0	  15372	 150704	  24cb0	rmk-rp2040

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.8% +10.3Ki  [ = ]       0    .debug_str
  +0.7% +7.22Ki  [ = ]       0    .debug_info
  +3.1% +2.48Ki  [ = ]       0    .debug_ranges
  +1.8% +2.06Ki  +1.8% +2.06Ki    .text
  +0.8% +1.26Ki  [ = ]       0    .debug_line
  [ = ]       0  +6.7%    +960    .bss
  +1.1%    +352  [ = ]       0    .symtab
  +0.7%    +120  +0.7%    +120    .rodata
  +0.2%     +40  [ = ]       0    .debug_frame
  +0.2%     +14  [ = ]       0    .debug_abbrev
  +0.0%      +8  [ = ]       0    .debug_aranges
   +16%      +7  [ = ]       0    [Unmapped]
  +0.2%      +1  [ = ]       0    .defmt
  -0.0%     -34  [ = ]       0    .strtab
  -0.5% -1.49Ki  [ = ]       0    .debug_loc
  +0.7% +22.4Ki  +2.1% +3.12Ki    TOTAL
use_config/rp2040_split (central) — 160.8 KiB → 163.9 KiB (+1.96% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 150692	      0	  17188	 167880	  28fc8	central

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 148412	      0	  16228	 164640	  28320	central

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.6% +10.3Ki  [ = ]       0    .debug_str
  +0.6% +7.00Ki  [ = ]       0    .debug_info
  +1.7% +2.11Ki  +1.7% +2.11Ki    .text
  +2.2% +1.96Ki  [ = ]       0    .debug_ranges
  +0.7% +1.21Ki  [ = ]       0    .debug_line
  [ = ]       0  +6.3%    +960    .bss
  +1.4%    +512  [ = ]       0    .symtab
  +0.6%    +120  +0.6%    +120    .rodata
  +0.2%     +40  [ = ]       0    .debug_frame
  +0.2%     +14  [ = ]       0    .debug_abbrev
  +0.0%      +8  [ = ]       0    .debug_aranges
  +0.2%      +1  [ = ]       0    .defmt
  -6.0%      -3  [ = ]       0    [Unmapped]
  -0.0%      -7  [ = ]       0    .strtab
  -1.7% -6.09Ki  [ = ]       0    .debug_loc
  +0.5% +17.2Ki  +2.0% +3.16Ki    TOTAL
use_config/rp2040_split (peripheral) — 27.8 KiB → 27.8 KiB (-0.01% ⬇️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
  25624	     60	   2796	  28480	   6f40	peripheral

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
  25628	     60	   2796	  28484	   6f44	peripheral

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.0%    +167  [ = ]       0    .debug_str
  +0.0%     +70  [ = ]       0    .debug_info
  +0.1%      +8  [ = ]       0    .debug_aranges
  +0.0%      +3  [ = ]       0    .debug_line
  +5.1%      +3  [ = ]       0    [Unmapped]
  -0.1%      -4  -0.1%      -4    .rodata
  -0.0%      -7  [ = ]       0    .strtab
  +0.0%    +240  -0.0%      -4    TOTAL
use_config/stm32f1 — 62.6 KiB → 64.6 KiB (+3.18% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
  57740	     28	   8408	  66176	  10280	rmk-stm32f1

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
  56600	     28	   7504	  64132	   fa84	rmk-stm32f1

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +1.1% +7.68Ki  [ = ]       0    .debug_str
  +0.7% +4.01Ki  [ = ]       0    .debug_info
  +2.0% +1.11Ki  +2.0% +1.11Ki    .text
  +0.8%    +953  [ = ]       0    .debug_loc
  [ = ]       0   +12%    +904    .bss
  +0.9%    +384  [ = ]       0    .debug_ranges
  +1.9%    +384  [ = ]       0    .symtab
  +0.3%    +246  [ = ]       0    .debug_line
  +1.5%    +200  [ = ]       0    .debug_frame
  +0.6%    +199  [ = ]       0    .strtab
  +1.5%     +96  [ = ]       0    .debug_aranges
  +0.5%      +4  +0.6%      +4    .rodata
 -36.2%     -21  [ = ]       0    [Unmapped]
  +0.9% +15.2Ki  +3.2% +2.00Ki    TOTAL
use_config/stm32h7 — 99.8 KiB → 102.1 KiB (+2.34% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
  93592	    268	  10680	 104540	  1985c	rmk-stm32h7

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
  92140	    268	   9736	 102144	  18f00	rmk-stm32h7

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.4% +6.78Ki  [ = ]       0    .debug_str
  +0.5% +5.07Ki  [ = ]       0    .debug_info
  +2.8% +4.78Ki  [ = ]       0    .debug_loc
  +1.7% +1.29Ki  +1.7% +1.29Ki    .text
  [ = ]       0  +9.7%    +944    .bss
  +0.5%    +328  [ = ]       0    .debug_ranges
  +0.2%    +234  [ = ]       0    .debug_line
  +0.8%    +224  [ = ]       0    .symtab
  +1.0%    +128  +1.0%    +128    .rodata
  +0.6%     +88  [ = ]       0    .debug_frame
  +0.1%     +48  [ = ]       0    .debug_aranges
 -19.6%     -11  [ = ]       0    [Unmapped]
  -0.4%     -27  [ = ]       0    .debug_abbrev
  -0.2%    -100  [ = ]       0    .strtab
  +0.6% +18.8Ki  +2.3% +2.34Ki    TOTAL
use_rust/nrf52832_ble — 359.6 KiB → 362.2 KiB (+0.72% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 331300	   5264	  34360	 370924	  5a8ec	rmk-nrf52832

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 329584	   5264	  33400	 368248	  59e78	rmk-nrf52832

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.5% +13.1Ki  [ = ]       0    .debug_str
  +0.3% +6.04Ki  [ = ]       0    .debug_info
  +0.5% +1.55Ki  +0.5% +1.55Ki    .text
  [ = ]       0  +3.0%    +960    .bss
  +0.4%    +904  [ = ]       0    .debug_ranges
  +0.2%    +500  [ = ]       0    .debug_line
  +0.1%    +154  [ = ]       0    .strtab
  +0.3%    +124  +0.3%    +124    .rodata
  +0.2%    +100  [ = ]       0    .debug_frame
  +0.1%     +96  [ = ]       0    .symtab
  +1.2%     +91  [ = ]       0    .debug_abbrev
  +0.1%     +48  [ = ]       0    .debug_aranges
   +19%     +10  [ = ]       0    [Unmapped]
  +0.2%      +1  [ = ]       0    .defmt
  -0.0%     -20  [ = ]       0    .debug_loc
  +0.3% +22.7Ki  +0.7% +2.61Ki    TOTAL
use_rust/nrf52840_ble — 418.1 KiB → 422.5 KiB (+1.05% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 377472	   5264	  49880	 432616	  699e8	rmk-nrf52840

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 376132	   5264	  46696	 428092	  6883c	rmk-nrf52840

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.4% +12.4Ki  [ = ]       0    .debug_str
  +0.4% +8.88Ki  [ = ]       0    .debug_info
  [ = ]       0  +7.0% +3.11Ki    .bss
  +0.3% +2.18Ki  [ = ]       0    .debug_loc
  +0.4% +1.19Ki  +0.4% +1.19Ki    .text
  +0.2%    +440  [ = ]       0    .debug_ranges
  +0.1%    +318  [ = ]       0    .debug_line
  +0.3%    +124  +0.3%    +124    .rodata
  +1.1%     +92  [ = ]       0    .debug_abbrev
 +10.0%      +6  [ = ]       0    [Unmapped]
  +0.1%      +1  [ = ]       0    .defmt
  -0.3%    -128  [ = ]       0    .debug_aranges
  -0.5%    -224  [ = ]       0    .debug_frame
  -0.2%    -532  [ = ]       0    .strtab
  -0.4%    -544  [ = ]       0    .symtab
  +0.3% +24.3Ki  +1.1% +4.42Ki    TOTAL
use_rust/nrf52840_ble_split (central) — 506.5 KiB → 510.1 KiB (+0.72% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 461240	   6588	  54544	 522372	  7f884	central

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 459272	   6588	  52768	 518628	  7e9e4	central

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.3% +11.8Ki  [ = ]       0    .debug_str
  +0.2% +5.35Ki  [ = ]       0    .debug_info
  +0.4% +1.80Ki  +0.4% +1.80Ki    .text
  +0.2% +1.79Ki  [ = ]       0    .debug_loc
  [ = ]       0  +3.4% +1.73Ki    .bss
  +0.2%    +781  [ = ]       0    .debug_line
  +0.2%    +624  [ = ]       0    .debug_ranges
  +0.3%    +124  +0.3%    +124    .rodata
   +40%     +17  [ = ]       0    [Unmapped]
  +0.1%      +1  [ = ]       0    .defmt
  -0.0%      -8  [ = ]       0    .debug_aranges
  -0.2%     -14  [ = ]       0    .debug_abbrev
  -0.1%     -32  [ = ]       0    .debug_frame
  -0.0%     -48  [ = ]       0    .symtab
  -0.1%    -331  [ = ]       0    .strtab
  +0.3% +21.8Ki  +0.7% +3.66Ki    TOTAL
use_rust/nrf52840_ble_split (peripheral) — 319.6 KiB → 320.2 KiB (+0.18% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 296204	   5360	  26360	 327924	  500f4	peripheral

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 295584	   5360	  26360	 327304	  4fe88	peripheral

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.1% +3.39Ki  [ = ]       0    .debug_str
  +0.1% +1.57Ki  [ = ]       0    .debug_info
  +0.2%    +620  +0.2%    +620    .text
  +0.0%    +113  [ = ]       0    .strtab
  +0.1%     +80  [ = ]       0    .symtab
  +0.1%     +32  [ = ]       0    .debug_frame
  +0.1%     +24  [ = ]       0    .debug_aranges
  +0.2%      +1  [ = ]       0    .defmt
  -0.0%      -2  [ = ]       0    .debug_abbrev
  -0.0%      -3  [ = ]       0    .debug_loc
 -14.3%      -9  [ = ]       0    [Unmapped]
  -0.1%    -322  [ = ]       0    .debug_line
  +0.1% +5.48Ki  +0.2%    +620    TOTAL
use_rust/pi_pico_w_ble — 659.9 KiB → 663.2 KiB (+0.49% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 622424	      0	  56660	 679084	  a5cac	rmk-pi-pico-w

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 619992	      0	  55748	 675740	  a4f9c	rmk-pi-pico-w

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.3% +10.6Ki  [ = ]       0    .debug_str
  +0.3% +7.49Ki  [ = ]       0    .debug_info
  +0.7% +2.25Ki  +0.7% +2.25Ki    .text
  +0.8% +1.82Ki  [ = ]       0    .debug_ranges
  +0.4% +1.51Ki  [ = ]       0    .debug_line
  [ = ]       0  +1.7%    +912    .bss
  +0.4%    +352  [ = ]       0    .symtab
  +0.0%    +124  +0.0%    +124    .rodata
  +0.1%     +40  [ = ]       0    .debug_frame
  +0.0%      +8  [ = ]       0    .debug_aranges
  +0.1%      +1  [ = ]       0    .defmt
  -3.3%      -2  [ = ]       0    [Unmapped]
  -0.0%     -40  [ = ]       0    .strtab
  -0.3% -3.46Ki  [ = ]       0    .debug_loc
  +0.2% +20.7Ki  +0.5% +3.27Ki    TOTAL
use_rust/rp2040 — 147.2 KiB → 150.2 KiB (+2.00% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 137576	      0	  16212	 153788	  258bc	rmk-rp2040

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 135516	      0	  15252	 150768	  24cf0	rmk-rp2040

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.7% +10.1Ki  [ = ]       0    .debug_str
  +0.8% +7.85Ki  [ = ]       0    .debug_info
  +2.5% +2.02Ki  [ = ]       0    .debug_ranges
  +1.6% +1.89Ki  +1.6% +1.89Ki    .text
  +0.7% +1.15Ki  [ = ]       0    .debug_line
  [ = ]       0  +6.7%    +960    .bss
  +1.0%    +320  [ = ]       0    .symtab
  +0.7%    +120  +0.7%    +120    .rodata
  +0.2%     +40  [ = ]       0    .debug_frame
  +0.2%     +14  [ = ]       0    .debug_abbrev
  +0.0%      +8  [ = ]       0    .debug_aranges
  -1.7%      -7  [ = ]       0    .defmt
 -28.6%     -16  [ = ]       0    [Unmapped]
  -0.0%     -40  [ = ]       0    .strtab
  -0.1%    -420  [ = ]       0    .debug_loc
  +0.7% +23.0Ki  +2.0% +2.95Ki    TOTAL
use_rust/rp2040_split (central) — 160.0 KiB → 162.9 KiB (+1.86% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 149896	      0	  16964	 166860	  28bcc	central

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 147804	      0	  16004	 163808	  27fe0	central

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.7% +10.6Ki  [ = ]       0    .debug_str
  +0.6% +6.84Ki  [ = ]       0    .debug_info
  +2.7% +2.39Ki  [ = ]       0    .debug_ranges
  +1.5% +1.93Ki  +1.5% +1.93Ki    .text
  +0.7% +1.29Ki  [ = ]       0    .debug_line
  [ = ]       0  +6.4%    +960    .bss
  +1.0%    +352  [ = ]       0    .symtab
  +0.6%    +116  +0.6%    +116    .rodata
  +0.2%     +40  [ = ]       0    .debug_frame
  +0.2%     +14  [ = ]       0    .debug_abbrev
  +0.0%      +8  [ = ]       0    .debug_aranges
  +0.2%      +1  [ = ]       0    .defmt
 -25.0%     -14  [ = ]       0    [Unmapped]
  -0.0%     -33  [ = ]       0    .strtab
  -1.1% -3.68Ki  [ = ]       0    .debug_loc
  +0.5% +19.8Ki  +1.9% +2.98Ki    TOTAL
use_rust/rp2040_split (peripheral) — 28.1 KiB → 28.1 KiB (-0.01% ⬇️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
  25696	     60	   3060	  28816	   7090	peripheral

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
  25700	     60	   3060	  28820	   7094	peripheral

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.0%    +167  [ = ]       0    .debug_str
  +0.0%     +70  [ = ]       0    .debug_info
   +22%     +10  [ = ]       0    [Unmapped]
  +0.1%      +8  [ = ]       0    .debug_aranges
  +0.0%      +3  [ = ]       0    .debug_line
  -0.1%      -4  -0.1%      -4    .rodata
  -0.0%      -6  [ = ]       0    .strtab
  +0.0%    +248  -0.0%      -4    TOTAL
use_rust/stm32f1 — 62.6 KiB → 64.1 KiB (+2.33% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
  57240	     28	   8384	  65652	  10074	rmk-stm32f1

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
  56644	     28	   7480	  64152	   fa98	rmk-stm32f1

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +1.0% +7.18Ki  [ = ]       0    .debug_str
  +0.6% +3.30Ki  [ = ]       0    .debug_info
  [ = ]       0   +12%    +904    .bss
  +1.7%    +704  [ = ]       0    .debug_ranges
  +1.1%    +592  +1.1%    +592    .text
  +1.9%    +384  [ = ]       0    .symtab
  +0.8%    +256  [ = ]       0    .strtab
  +1.9%    +248  [ = ]       0    .debug_frame
  +0.2%    +218  [ = ]       0    .debug_loc
  +2.5%    +120  [ = ]       0    .debug_aranges
   +20%     +10  [ = ]       0    [Unmapped]
  +0.6%      +4  +0.6%      +4    .rodata
  -0.0%      -3  [ = ]       0    .debug_line
  +0.8% +13.0Ki  +2.3% +1.46Ki    TOTAL
use_rust/stm32h7 — 121.2 KiB → 124.3 KiB (+2.59% ⬆️)

cargo size (PR):

   text	   data	    bss	    dec	    hex	filename
 110516	    324	  16484	 127324	  1f15c	rmk-stm32h7

cargo size (main):

   text	   data	    bss	    dec	    hex	filename
 108252	    324	  15524	 124100	  1e4c4	rmk-stm32h7

Bloaty diff (PR vs main):

    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.5% +10.1Ki  [ = ]       0    .debug_str
  +0.6% +6.67Ki  [ = ]       0    .debug_info
  +1.2% +2.81Ki  [ = ]       0    .debug_loc
  +2.2% +2.20Ki  +2.2% +2.20Ki    .text
  +2.1% +1.68Ki  [ = ]       0    .debug_ranges
  +1.0% +1.49Ki  [ = ]       0    .debug_line
  [ = ]       0  +6.6%    +960    .bss
  +0.2%    +145  [ = ]       0    .strtab
  +0.3%     +16  +0.3%     +16    .rodata
  +0.0%     +16  [ = ]       0    .symtab
  +4.0%      +2  [ = ]       0    [Unmapped]
  +0.3%      +1  [ = ]       0    .defmt
  +0.6% +25.1Ki  +2.6% +3.15Ki    TOTAL

@HaoboGu

HaoboGu commented May 21, 2026

Copy link
Copy Markdown
Owner

There is One Shot Sticky Modifier in main branch, what's the difference?

@ldsands

ldsands commented May 21, 2026

Copy link
Copy Markdown
Contributor Author

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.

@HaoboGu

HaoboGu commented May 21, 2026

Copy link
Copy Markdown
Owner

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 SK(key, keep, max_repeat):

  • When this SK is triggered, key is activated
  • key keeps to be activated if keys in keep list is pressed
  • key is released when keep is pressed for max_repeat times, or any other keys is triggered

With this, one-shot mod can be represented as SK(mod, [], 1), and SM(Tab, LAlt) can be represented as SK(Tab, [LAlt], MAX_REPEAT)

What do you think?

@ldsands

ldsands commented May 21, 2026

Copy link
Copy Markdown
Contributor Author

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 SK(key, keep, max_repeat):

  • When this SK is triggered, key is activated
  • key keeps to be activated if keys in keep list is pressed
  • key is released when keep is pressed for max_repeat times, or any other keys is triggered

With this, one-shot mod can be represented as SK(mod, [], 1), and SM(Tab, LAlt) can be represented as SK(Tab, [LAlt], MAX_REPEAT)

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?

@HaoboGu

HaoboGu commented May 21, 2026

Copy link
Copy Markdown
Owner

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.

Yes, I agree. Omitting it means "infinite", i.e. SK(Tab, [LAlt], MAX_REPEAT) == SK(Tab, [LAlt]).
But I'm not sure what the default behavior should be: max_repeat == 1 or infinite?

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.

I also wonder how this interacts with layer changes.

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.

@ldsands

ldsands commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

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.

@HaoboGu

HaoboGu commented May 29, 2026

Copy link
Copy Markdown
Owner

Great!

Only a minor comment left in #854, I think we can get it merged first.

@HaoboGu

HaoboGu commented May 29, 2026

Copy link
Copy Markdown
Owner

#854 is merged

ldsands added 15 commits May 30, 2026 16:48
- 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
@ldsands

ldsands commented May 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback on consolidating SM and OSM! I've rebased this branch onto the latest main (which includes PR #854's quick_release feature) and reworked the implementation to address your request.

What changed:

  • Removed StickyMod entirely — replaced by the unified StickyKey (SK) action
  • SK(key, [modifier], max_repeat, timeout_ms, exit_on_layer_change) — all args after key are optional
    • max_repeat = 0 → infinite repeats (default)
    • timeout_ms = 0 → uses global [behavior.sticky_key] timeout (default; no timeout if unset)
    • exit_on_layer_change → defaults to false (modifier survives layer changes)
  • OSM remains as-is for the one-shot use case; SK handles the "hold modifier across repeated presses" use case
  • 11 integration tests added for SK behavior; all 450 tests passing
  • Docs updated in behavior.md and layout.md

Example usage: SK(Tab, [LAlt]) for Alt+Tab window cycling — first press sends Alt+Tab, each subsequent press sends Tab again while holding Alt, releases when you press anything else.

@ldsands ldsands force-pushed the feat/sticky-mod branch from 2a2f462 to 8d51d88 Compare May 30, 2026 23:56
ldsands added 3 commits June 2, 2026 11:05
…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.
ldsands added 24 commits June 5, 2026 23:33
…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
…difier OSL non-consume (Stage 3 review fixes)
…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.
@ldsands

ldsands commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@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 SK feature

This consolidates the previously separate one-shot modifier (OSM), one-shot layer (OSL), and sticky-key mechanisms into one SK(...) action driven by a
single engine. The old oneshot.rs runtime is deleted and folded into sticky_key.rs; one config table and one keymap action now cover everything.

The three SK shapes

SK picks its behavior from the shape of its argument:

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_change survive/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, covering activate_on_keypress and quick_release (chain + quick-release modes, combined modifiers).
  • All five [behavior.sticky_key] fields are exercised; the in-repo use_config examples 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.

@HaoboGu

HaoboGu commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Sorry for the delay, I'm quite busy these days.

A few comments/questions:

  1. For pure mod and layer mode, the max_repeat is set to 1 and for tap key mode, the max_repeat is set to 0 automatically? If so this should be added to the doc I think
  2. I don't want to remove OSM/OSL from the user space(users are already familiar with those concepts), only change the internal implementation.
  3. Why activate_on_keypress/quick_release don't work on normal SK?

@ldsands

ldsands commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Sorry for the delay, I'm quite busy these days.

@HaoboGu, No problem, I'm in the same boat, way too much to do, far too little time.

A few comments/questions:

  1. For pure mod and layer mode, the max_repeat is set to 1 and for tap key mode, the max_repeat is set to 0 automatically? If so this should be added to the doc I think

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.

  1. I don't want to remove OSM/OSL from the user space(users are already familiar with those concepts), only change the internal implementation.

My bad, I'll add them back as aliases so it'll all use as much of the same code as possible.

  1. Why activate_on_keypress/quick_release don't work on normal SK?

These are one-shot-modifier semantics, so they only apply to the pure-mod shape (OSM, equivalently SK(mod)):

  • activate_on_keypress controls whether the modifier is sent to the host the instant the SK key itself is pressed (true), versus deferring it until the next key (false,
    default). The tap-key shape already sends mod + key immediately on every press, so the flag would be a redundant no-op there.
  • quick_release controls whether the modifier releases on the next key's press (true) or its release (false, default chain mode). The tap-key shape has no single terminating
    key — it cycles the same key and releases on any other key — so there's nothing for that distinction to act on.
  • The layer shape sends no modifier at all, so neither applies.

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
@HaoboGu

HaoboGu commented Jun 19, 2026

Copy link
Copy Markdown
Owner

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants