Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
134 commits
Select commit Hold shift + click to select a range
a739189
feat(types): add Action::StickyMod variant for sticky modifier behavior
ldsands Apr 11, 2026
30b57b0
feat(keyboard): add sticky_mod module with state machine and processi…
ldsands Apr 11, 2026
cf1aea8
feat(macro): add sm!() macro for StickyMod key actions
ldsands Apr 11, 2026
be07b53
feat(keyboard): integrate StickyMod state, dispatch, modifier resolut…
ldsands Apr 11, 2026
baf588d
fix: restore removed comments and clean up unused import
ldsands Apr 11, 2026
fdd0b97
feat(config): add SM(key, modifier) TOML syntax for StickyMod
ldsands Apr 11, 2026
53db332
fix(keyboard): also exclude modifier HID keycodes from StickyMod rele…
ldsands Apr 11, 2026
f8937df
test: add integration tests for StickyMod action
ldsands Apr 11, 2026
9cb1988
feat(config): add [behavior.sticky_mod] config with optional timeout
ldsands Apr 11, 2026
e4c0668
fix(config): export StickyModConfig from rmk::config module
ldsands Apr 11, 2026
94eb66c
fix: add rusty-fork dev dep and await in test after rebase
ldsands May 9, 2026
b1375e7
fix/docs/test: fix DurationMillis visibility, add SM parser tests, ad…
ldsands May 15, 2026
bb1b111
feat(keyboard): implement StickyMod timeout via main event loop deadline
ldsands May 21, 2026
a6aa190
style: apply cargo fmt
ldsands May 21, 2026
c7d54b1
fix: simplify map_or to is_some_and (clippy); update wire-format snap…
ldsands May 21, 2026
a6a0b04
chore: start feat/sticky-key branch (from feat/sticky-mod)
ldsands May 28, 2026
273a1dc
test: add keyboard_sticky_key_test.rs with 11 SK tests (failing — typ…
ldsands May 28, 2026
5528f9a
feat(types): add StickyKeyAction struct and Action::StickyKey variant
ldsands May 28, 2026
c8c00b8
feat(config): add StickyKeyConfig and sticky_key field to BehaviorConfig
ldsands May 28, 2026
fd475b8
feat(macro): add sk!() macro for StickyKey action
ldsands May 29, 2026
262e496
feat(keyboard): add sticky_key.rs state machine with max_repeat, time…
ldsands May 29, 2026
cff9c5d
feat(keymap): add sticky_key_timeout() accessor
ldsands May 29, 2026
6387590
feat(keyboard): integrate StickyKey — dispatch, mods, deadline, layer…
ldsands May 29, 2026
c20c91d
fix(keyboard): correct expected reports in test_sk_survives_layer_change
ldsands May 29, 2026
e266ea4
feat(config): replace StickyMod TOML config/parser with StickyKey; ad…
ldsands May 29, 2026
c170eca
feat(macro): replace SM codegen with SK codegen; parse SK(key,[keep],…
ldsands May 29, 2026
fc2bb3a
refactor: remove all StickyMod code — replaced by StickyKey
ldsands May 30, 2026
2707126
chore(test): remove unused StickyKeyAction import
ldsands May 30, 2026
33c2415
fix(keyboard): skip release report when max_repeat deactivates SK sil…
ldsands May 30, 2026
ffaf0d7
docs: update behavior.md and layout.md for StickyKey (SK) — SM docs w…
ldsands May 30, 2026
3d8d572
fix: expand matches! macro in sticky_key.rs for rustfmt; regenerate w…
ldsands May 31, 2026
3de6145
fix(ci): regenerate bulk endpoint snapshot under host features; apply…
ldsands May 31, 2026
61aa4cf
Merge branch 'main' into feat/sticky-mod
HaoboGu May 31, 2026
1af5cde
docs: design spec for unifying OSM and StickyKey runtime
ldsands Jun 3, 2026
10c34d7
docs: add SK global-default + per-key profile override to design spec
ldsands Jun 3, 2026
1624662
docs: add profile-first configuration requirement to SK config section
ldsands Jun 3, 2026
b076f2a
docs: add spec for SK absorbing one-shot (OSM+OSL), full replacement
ldsands Jun 3, 2026
4e5ff1f
docs: drop OSM/OSL syntax entirely; mark wire/Vial impact and OSK as …
ldsands Jun 3, 2026
8e8aa09
docs: staged implementation plan for SK absorbing OSM+OSL
ldsands Jun 3, 2026
fea8f34
docs: characterize OSM/OSL/SK behavior parity catalogue (Stage 0)
ldsands Jun 4, 2026
cbb7516
feat(config): collapse one_shot/one_shot_modifiers/sticky_key into un…
ldsands Jun 4, 2026
9ae4008
feat(config): single [behavior.sticky_key] TOML table, resolve, and c…
ldsands Jun 4, 2026
a24b198
feat(parser): SK(LGui)/SK(MO(n)) shapes; drop OSM/OSL keywords and 5-…
ldsands Jun 5, 2026
f19c473
docs(plan): record DP-1 (layer Option<u8>) and DP-3 (keep Vial 0x06) …
ldsands Jun 5, 2026
28416fa
feat(wire): StickyKeyAction carries layer payload; remove OneShotModi…
ldsands Jun 6, 2026
cefc6e1
test: migrate one-shot/sticky tests to SK(...) syntax and unified con…
ldsands Jun 6, 2026
77029ec
docs(plan): mark Stage 1 complete; record gate status and carry-forwa…
ldsands Jun 6, 2026
1056db2
docs(plan): record DP-2 (fold oneshot.rs into sticky_key.rs, delete o…
ldsands Jun 6, 2026
f329ca4
feat(engine): unified SK latch carrying mods/key/layer/phase/repeat/d…
ldsands Jun 6, 2026
d2a1749
fix(macro): sk_mod!/sk_layer! emit KeyCode::Hid(HidKeyCode::No) (Stag…
ldsands Jun 6, 2026
6b1175b
docs(plan): mark Task 2.1 complete; record sentinel fix + declined ph…
ldsands Jun 6, 2026
565a804
docs(plan): record Stage 2 execution decision — combine engine Tasks …
ldsands Jun 6, 2026
e0a67bf
feat(engine): pure-mod SK shape — accumulation, activate_on_keypress/…
ldsands Jun 6, 2026
e9a0c09
refactor(engine): retire OSM path + inline select; remove OSM/OSL unp…
ldsands Jun 6, 2026
35e62c7
fix(engine): clear deadline on pure-mod Held promotion (OSM no-timeou…
ldsands Jun 6, 2026
f532398
docs(plan): mark Stage 2 complete; record reviews, test gate, and rev…
ldsands Jun 6, 2026
0171d6d
docs(plan): record Stage 3 D1/D2 design decision — preserve OSM+OSL c…
ldsands Jun 6, 2026
fe1e4ec
feat(engine): absorb OSL — SK(MO(n)) layer shape on unified latch; de…
ldsands Jun 7, 2026
9f03aac
docs(engine): fix stale unprocessed_events comment + document bare-mo…
ldsands Jun 7, 2026
fe44f3b
docs(plan): mark Stage 3 complete; record reviews, test gate, and rev…
ldsands Jun 7, 2026
242d594
docs: SK shapes + unified [behavior.sticky_key]; pure-mod vs tap-key …
ldsands Jun 7, 2026
d34538f
docs(plan): mark Stage 4 complete; record doc rewrite, reviews, and f…
ldsands Jun 8, 2026
e32c4d9
test: update SK grammar tests for 3-shape design; fmt SK files
ldsands Jun 9, 2026
e0f9206
fix(sticky-key): replace foreign-shape latch on press; harden repeat/…
ldsands Jun 10, 2026
4a9c1b3
docs(sticky-key): record DP-4 wire/Vial/storage migration findings
ldsands Jun 10, 2026
57071fb
chore: drop internal SK design/plan docs (not for upstream PR)
ldsands Jun 12, 2026
8b96965
Merge remote-tracking branch 'upstream/main' into feat/sticky-mod
ldsands Jun 12, 2026
b25fb3d
fix(examples): migrate use_config keymaps OSM/OSL -> SK(...)
ldsands Jun 12, 2026
378a7b8
fix(macro): use re-exported ::rmk::embassy_time in sticky_key codegen
ldsands Jun 12, 2026
473c595
test(sticky-key): assert activate_on_keypress/quick_release ignored o…
ldsands Jun 12, 2026
5ac8d57
feat(sticky-key): restore OSM/OSL as aliases for SK(mod)/SK(MO(n))
ldsands Jun 18, 2026
b00e3be
Merge upstream/main into feat/sticky-mod
ldsands Jun 19, 2026
ebfe286
Merge upstream/main into feat/sticky-mod (retrigger CI)
ldsands Jun 24, 2026
34a6559
refactor(resolved): group sticky_key fields into StickyKeyConfig struct
ldsands Jul 2, 2026
d2d35f4
feat(sticky-key): add osl!/osm! macros as sk_layer!/sk_mod! aliases
ldsands Jul 3, 2026
42dec11
chore: remove rusty-fork dev-dependency
ldsands Jul 3, 2026
ed70086
fix(sticky-key): guard timeout release while held; refresh deadline o…
ldsands Jul 3, 2026
acbe4f3
fix(sticky-key): guard timeout release while held; refresh deadline o…
ldsands Jul 3, 2026
2f30a0c
fix: trailing newline, with_deadline scope, and add test for timeout-…
ldsands Jul 3, 2026
231df5c
feat(sticky-key): OSM + OSL coexistence — restore parallel one-shot m…
ldsands Jul 3, 2026
d573b64
style: apply CI nightly rustfmt to all changes
ldsands Jul 3, 2026
6385513
fix(vial): add OSM/OSL ↔ StickyKey conversion via keycode_convert.rs
ldsands Jul 6, 2026
07b2ac6
fix(keyboard): use per-check Instant::now() instead of shared now to …
ldsands Jul 6, 2026
2d6a2b2
Merge remote-tracking branch 'upstream/main' into feat/sticky-mod
ldsands Jul 7, 2026
bb3f83e
fix: remove extra closing brace and apply formatting after merge
ldsands Jul 7, 2026
63f52cb
fix: apply rustfmt to via keycode_convert.rs comment spacing
ldsands Jul 7, 2026
43bb66c
fix: apply rustfmt to behavior.rs import ordering
ldsands Jul 7, 2026
3386146
fix: revert user_build.yml and .vscode/settings.json to upstream/main
ldsands Jul 9, 2026
1a46b79
fix: preserve StickyKey shape in VIA conversion
ldsands Jul 10, 2026
80d6425
fix: defer StickyKey timeout while held
ldsands Jul 11, 2026
a05a994
fix: isolate concurrent StickyKey presses
ldsands Jul 11, 2026
301c635
Merge remote-tracking branch 'upstream/main' into feat/sticky-mod
ldsands Jul 11, 2026
2088d64
perf(sticky-key): compact timeout state
ldsands Jul 11, 2026
5b33985
perf(sticky-key): share action setup
ldsands Jul 11, 2026
1f12fe6
Revert "perf(sticky-key): share action setup"
ldsands Jul 11, 2026
603ad06
fix(sticky-key): store HID keycodes directly
ldsands Jul 15, 2026
4f0e905
feat(sticky-key): add per-shape layer change release
ldsands Jul 15, 2026
970daa5
Merge remote-tracking branch 'upstream/main' into feat/sticky-mod
ldsands Jul 15, 2026
9409c71
feat(sticky-key): clarify one-shot-layer release setting
ldsands Jul 16, 2026
3c2a044
fix(sticky-key): handle held tap-key replacement and timeout
ldsands Jul 16, 2026
a487faa
style: format sticky-key codegen test
ldsands Jul 16, 2026
a54b1dc
docs: clarify sticky key timeout behavior
ldsands Jul 17, 2026
968d94b
feat(sticky-key): add configurable profiles
ldsands Jul 18, 2026
b0e6a63
fix(sticky-key): preserve profile release semantics
ldsands Jul 18, 2026
432d46f
fix(config): preserve sticky profile references
ldsands Jul 18, 2026
61cc255
fix: satisfy clippy collapsible-if lints
ldsands Jul 18, 2026
ff3ef68
style: group auto mouse layer imports
ldsands Jul 18, 2026
36f2642
refactor(sticky-key): simplify lifecycle state
ldsands Jul 19, 2026
c072015
feat(sticky-key): align feature with current main
ldsands Jul 20, 2026
354b22e
refactor(sticky-key): align lifecycle with RMK patterns
ldsands Jul 20, 2026
1cdac47
fix(sticky-key): harden lifecycle correctness
ldsands Jul 21, 2026
999d369
fix(sticky-key): harden ownership and compatibility
ldsands Jul 21, 2026
0490324
refactor(sticky-key): unify parser and alias paths
ldsands Jul 21, 2026
0df473d
docs(sticky-key): clarify composition and migration
ldsands Jul 21, 2026
b97ad7d
Merge Sticky Key RMK alignment
ldsands Jul 23, 2026
9a2ac53
fix(sticky-key): harden ownership and action identity
ldsands Jul 23, 2026
c16e91c
Merge upstream/main into Sticky Key branch
ldsands Jul 23, 2026
c8eed0d
chore: update example lockfiles
ldsands Jul 23, 2026
c0984c0
Merge upstream/main into feat/sticky-mod
ldsands Jul 23, 2026
d5026f7
docs(sticky-key): clarify configuration semantics
ldsands Jul 23, 2026
8e7660d
Merge remote-tracking branch 'upstream/main' into feat/sticky-mod
ldsands Jul 24, 2026
72116ba
Merge upstream/main into feat/sticky-mod
ldsands Jul 31, 2026
b6ba43e
Merge upstream/main into feat/sticky-mod
ldsands Aug 1, 2026
d140dec
Merge latest upstream/main into feat/sticky-mod
ldsands Aug 1, 2026
b5e05a1
fix sticky key timeout codegen path
ldsands Aug 2, 2026
dae94f6
refactor(sticky-key): address PR review feedback
ldsands Aug 2, 2026
0f2d0d9
feat: release held sticky keys after timeout
ldsands Aug 2, 2026
17d3044
fix: derive defmt format for sticky modifier producers
ldsands Aug 2, 2026
4974df9
feat: configure sticky key hold release duration
ldsands Aug 2, 2026
cf9555f
refactor: rename sticky hold release setting
ldsands Aug 3, 2026
d0ebec0
chore: remove sticky key implementation plan
ldsands Aug 3, 2026
03eb1a9
refactor sticky key state around actions
ldsands Aug 3, 2026
4667389
test sticky key consolidation edge cases
ldsands Aug 4, 2026
2477308
Merge branch 'main' into feat/sticky-mod
HaoboGu Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 23 additions & 31 deletions docs/docs/main/docs/configuration/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,46 +24,38 @@ adjust = 3

In this example, when both layers 1 (`upper`) and 2 (`lower`) are active, layer 3 (`adjust`) will also be enabled.

## One-Shot
## Sticky Keys

The `one_shot` sub-table contains common one-shot configuration (for both OSM and OSL)
Sticky Keys unify one-shot modifiers, one-shot layers, and repeated modified keys. Use `SK(LShift)` for a modifier, `SK(MO(1))` for a layer, or `SK(Tab, [LAlt])` for an Alt-Tab-style key. `OSM(...)` and `OSL(...)` remain compatibility aliases for the first two forms.

Currently, there are only `timeout` field that specifies how long the one-shot modifier/layer remains active. When no key is pressed within this time, the one-shot modifier/layer will be canceled. `timeout` value is a string suffixed with `s` or `ms` (default: `1s`).

## One-Shot Modifiers

The `one_shot_modifiers` sub-table configures one-shot modifiers (OSM).

By default, one-shot modifiers do not activate on keypress and will be sent only when other key is pressed. You can change this behavior by setting `activate_on_keypress` to `true`. This behavior is also known as One-Shot Sticky Modifiers (OSSM).

If you press One-Shot Modifier again, it will be sent as a normal modifier key press and, therefore, released.

The `quick_release` option controls when the one-shot modifier is released:

- `false` (default): the modifier is released when the next key is **released** (chain mode, equivalent to ZMK `&skn`). The modifier stays active for the entire duration of the next keypress, including key repeat.
- `true`: the modifier is released when the next key is **pressed** (equivalent to ZMK `&skq`). Only the initial press of the next key is modified; key repeat will not include the modifier.
```toml
[behavior.sticky_key]
timeout = "1s"
activate_on_keypress = true
release_after_hold = "500ms"
max_repeat = 0
release_mode = "other_key_release"

Default values:
[behavior.sticky_key.profiles.quick]
release_mode = "other_key_press | double_tap"

```toml
[behavior.one_shot_modifiers]
activate_on_keypress = false
quick_release = false
[behavior.sticky_key.profiles.alt_tab]
timeout = "5s"
max_repeat = 8
release_mode = "other_key_press | layer_exit"
```

OSSM example:
Each field may be omitted. Named profiles inherit omitted fields from `[behavior.sticky_key]` and are selected with `@name`, for example `SK(LShift, @quick)` or `SK(Tab, [LAlt], @alt_tab)`.

```toml
[behavior.one_shot_modifiers]
activate_on_keypress = true
```
- `timeout`: releases an unused latch after this duration; the default is `1s`.
- `activate_on_keypress`: reports a pure modifier immediately instead of waiting for the next key.
- `release_after_hold`: for modifiers or layers held for at least this duration, releases the modifier or layer when the Sticky Key is physically released instead of latching it. A shorter tap receives the full `timeout` starting from key-up. This hold threshold is independent of `timeout` and may be shorter or longer. Pure modifiers must also set `activate_on_keypress = true` to be reported while physically held. Modified tap keys are unaffected. The default is disabled.
- `max_repeat`: limits tap-key repetitions; `0` means unlimited.
- `release_mode`: a `|`-separated set of `other_key_press`, `other_key_release`, `layer_enter`, `layer_exit`, and `double_tap`.

Quick-release example:
The legacy `[behavior.one_shot] timeout` and `[behavior.one_shot_modifiers]` settings are still accepted and feed the default Sticky profile. `quick_release = true` maps to `other_key_press` for pure-modifier aliases.

```toml
[behavior.one_shot_modifiers]
quick_release = true
```
Sticky layers use RMK's normal boolean layer state: when multiple actions target the same layer, the latest activate or deactivate command determines its state.

## Combo

Expand Down
22 changes: 12 additions & 10 deletions docs/docs/main/docs/configuration/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,16 +270,16 @@ The `layer.keys` string follows several rules:

4. RMK supports many advanced layer operations:
1. `DF(n)` — switch the default layer to layer `n`.
2. `PDF(n)` — switch the default layer to layer `n` and persist it to storage, so it survives reboots.
3. `MO(n)` — momentarily activate layer `n`.
4. `LM(n, modifier)` — activate layer `n` with a modifier held. The modifier chains like `WM`.
5. `LT(n, key, <profile_name>)` — activate layer `n` on hold, or tap `key` (tap/hold). `key` is an RMK [`KeyCode`](./keymap_configuration/keycodes); the optional `profile_name` sets the key's [profile](./behavior#per-key-profiles-for-morse-tapdance-tap-hold-fine-tuning).
6. `OSL(n)` — one-shot layer `n`.
7. `OSM(modifier)` — one-shot modifier. The modifier chains like `WM`.
8. `TT(n)` — activate layer `n`, or tap-toggle it.
9. `TG(n)` — toggle layer `n`.
10. `TO(n)` — activate layer `n` and deactivate all other layers.

2. `MO(n)` — momentarily activate layer `n`.
3. `LM(n, modifier)` — activate layer `n` with a modifier held. The modifier chains like `WM`.
4. `LT(n, key, <profile_name>)` — activate layer `n` on hold, or tap `key` (tap/hold). `key` is an RMK [`KeyCode`](./keymap_configuration/keycodes); the optional `profile_name` sets the key's [profile](./behavior#per-key-profiles-for-morse-tapdance-tap-hold-fine-tuning).
5. `OSL(n)` — compatibility alias for the sticky layer `SK(MO(n))`.
6. `OSM(modifier)` — compatibility alias for the sticky modifier `SK(modifier)`. The modifier chains like `WM`.
7. `TT(n)` — activate layer `n`, or tap-toggle it.
8. `TG(n)` — toggle layer `n`.
9. `TO(n)` — activate layer `n` and deactivate all other layers.
11. `PDF(n)` — switch the default layer to layer `n` and persist it to storage, so it survives reboots.
These match QMK's definitions; see the [QMK layer docs](https://docs.qmk.fm/#/feature_layers). If you need another action, please [file an issue](https://github.com/rmk-rs/rmk/issues/new).

5. For modifier-tap-hold, use `MT(key, modifier, <profile_name>)`, where the modifier can be a chain as in rule 1. The optional `profile_name` sets the key's [profile](./behavior#per-key-profiles-for-morse-tapdance-tap-hold-fine-tuning).
Expand All @@ -295,6 +295,8 @@ The `layer.keys` string follows several rules:

9. For keyboard macros, use `Macro(n)`.

10. For a configurable Sticky Key, use `SK(modifier)`, `SK(MO(n))`, or `SK(key, [modifiers])`. An optional named profile is written as the final `@profile` argument: `SK(LShift, @quick)` or `SK(Tab, [LAlt], @alt_tab)`. See [Sticky Keys](./behavior#sticky-keys).

## Aliases

The `[aliases]` section maps user-defined names to replacement strings, which you can then use in `layer.keys`:
Expand Down
5 changes: 4 additions & 1 deletion docs/docs/main/docs/configuration/rmk_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ fork_max_num = 8
morse_max_num = 8
# Maximum number of named morse profiles, shared by morse and tap-hold keys (max 255)
morse_profile_max_num = 16
# Maximum number of named Sticky Key profiles (max 255). If omitted, RMK derives it.
# sticky_key_profile_max_num = 4
# Maximum number of patterns a morse key can handle (default: 8, min: 4, max 65536)
max_patterns_per_key = 8
# Macro space size in bytes for storing sequences. The maximum number of Macros depends on the size of each sequence: All sequences combined need to fit into macro_space_size, the number of macro sequences doesn't matter.
Expand Down Expand Up @@ -66,7 +68,8 @@ Increasing the number of combos, forks, morses (tap dances), and macros will inc
- `fork_max_num`: Maximum number of forks for conditional key actions, default value is 8. This value must be between 0 and 255.
- `morse_max_num`: Maximum number of morses that can be stored, default value is 8. This value must be between 0 and 255.
- `morse_profile_max_num`: Capacity of the morse profile table (the named profiles in `[behavior.morse.profiles]`, referenced by morse and tap-hold keys), default value is 16. This value must be between 0 and 255.
- `max_patterns_per_key` : Maximum number of tap/hold patterns a morse key can handle, default value is 8. This value must be between 4 and 65536. (Automatically raised to fit the largest `tap_actions` + `hold_actions` + `morse_actions` count among the configured morse keys.)
- `sticky_key_profile_max_num`: Capacity of the named Sticky Key profile table. If omitted, RMK derives a sufficient value from the configured profiles with a small Rust-API fallback; an explicit value must be between 0 and 255.
- `max_patterns_per_key` : Maximum number of tap/hold patterns a morse key can handle, default value is 8. This value must be between 4 and 65536. (Will be automatically set to the maximum length of `tap_actions` + `hold_actions` or `morse_actions`.)
- `macro_space_size`: Space size in bytes for storing macro sequences, default value is 256.

### Matrix Configuration
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/main/docs/development/rynk_protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# Rynk Protocol Reference

Current protocol version: **0.1**.
Current protocol version: **1.0**.

Every transport (USB CDC, BLE GATT, BLE HID) carries the same frame — a 3-byte header plus a [postcard](https://docs.rs/postcard)-encoded payload:

Expand Down
23 changes: 18 additions & 5 deletions rmk-config/src/keymap.pest
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ keycode_name = @{ loose_identifier }
// contexts where it won't conflict with argument separators.
symbol_keycode = @{ "," }
profile_name = { strict_identifier }
alias_ref = @{ "@" ~ strict_identifier }

// Number (for layer indices)
number = @{ ASCII_DIGIT+ }
Expand Down Expand Up @@ -55,7 +56,8 @@ wm_action = { ^"WM" ~ "(" ~ keycode_name ~ "," ~ modifier_combination ~ ")" }
mod_action = { ^"MOD" ~ "(" ~ modifier_combination ~ ")" }

// Rule 4.6: OSM(modifier) - One-Shot Modifier (requires quotes)
osm_action = { ^"OSM" ~ "(" ~ modifier_combination ~ ")" }
sticky_profile_ref = { "@" ~ profile_name }
osm_action = { ^"OSM" ~ "(" ~ modifier_combination ~ ("," ~ sticky_profile_ref)? ~ ")" }

// Rule 4.1: DF(n) - Switch Default Layer
df_action = { ^"DF" ~ "(" ~ layer_reference ~ ")" }
Expand All @@ -73,7 +75,7 @@ lm_action = { ^"LM" ~ "(" ~ layer_reference ~ "," ~ modifier_combination ~ ")" }
lt_action = { ^"LT" ~ "(" ~ layer_reference ~ "," ~ nestable_action ~ ("," ~ profile_name)? ~ ")" }

// Rule 4.5: OSL(n) - One-Shot Layer
osl_action = { ^"OSL" ~ "(" ~ layer_reference ~ ")" }
osl_action = { ^"OSL" ~ "(" ~ layer_reference ~ ("," ~ sticky_profile_ref)? ~ ")" }

// Rule 4.7: TT(n) - Layer Activate or Tap Toggle
tt_action = { ^"TT" ~ "(" ~ layer_reference ~ ")" }
Expand All @@ -96,9 +98,9 @@ layer_action = _{
// `keycode_name` fallback (not `simple_keycode`) keeps a lone `,` from being
// accepted as a slot argument.
nestable_action = _{
wm_action | mod_action | osm_action | shifted_action | trigger_macro_action |
wm_action | mod_action | osm_action | shifted_action | trigger_macro_action | alias_ref |
df_action | pdf_action | mo_action | lm_action | osl_action | tg_action | to_action |
stn_action | keycode_name
stn_action | sk_action | keycode_name
}

// Rule 5: MT(key, modifier) - Modifier Tap-Hold
Expand All @@ -116,6 +118,17 @@ morse_action = { (^"TD" | ^"MORSE") ~ "(" ~ number ~ ")" }
// Rule 9: Macro(n) - Trigger Macro
trigger_macro_action = { ^"MACRO" ~ "(" ~ number ~ ")" }

modifier_keep_list = { "[" ~ modifier_combination ~ "]" | "[" ~ "]" }

// SK(key, [mods], @profile) | SK(modifier, @profile) | SK(MO(n), @profile)
sk_action = {
^"SK" ~ "(" ~ (
mo_action ~ ("," ~ sticky_profile_ref)?
| (keycode_name ~ "," ~ modifier_keep_list ~ ("," ~ sticky_profile_ref)?)
| modifier_combination ~ ("," ~ sticky_profile_ref)?
) ~ ")"
}

// Rule 10: STN(key) - Plover HID steno key, named by its position in the chart
stn_action = { ^"STN" ~ "(" ~ keycode_name ~ ")" }

Expand All @@ -124,7 +137,7 @@ stn_action = { ^"STN" ~ "(" ~ keycode_name ~ ")" }
// A single key action entry in the map
// Order is important: more specific function-like rules first, then aliases/specials, then simple keycodes.
key_action = _{ // Consume surrounding whitespace/comments implicitly
wm_action | mod_action | osm_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | stn_action | no_action | transparent_action | simple_keycode
wm_action | mod_action | osm_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | stn_action | sk_action | alias_ref | no_action | transparent_action | simple_keycode
}

// The entire key map string: Start, zero or more key actions, End.
Expand Down
104 changes: 59 additions & 45 deletions rmk-config/src/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,63 +205,77 @@ impl KeyboardTomlConfig {
fn alias_resolver(keys: &str, aliases: &HashMap<String, String>) -> Result<String, String> {
let mut current_keys = keys.to_string();

let mut iterations = 0;

loop {
let mut next_keys = String::with_capacity(current_keys.capacity());
let mut made_replacement = false;
let mut last_index = 0;

while let Some(at_index) = current_keys[last_index..].find('@') {
let start_index = last_index + at_index;

next_keys.push_str(&current_keys[last_index..start_index]);

// The name ends at the grammar's delimiters, so `LM(1, @mods)`
// resolves `mods` — not a bogus `mods)`.
let mut end_index = start_index + 1;
while let Some(&c) = current_keys.as_bytes().get(end_index) {
if c.is_ascii_whitespace() || matches!(c, b'(' | b')' | b',' | b'@') {
for _ in 0..MAX_ALIAS_RESOLUTION_DEPTH {
let mut next = String::with_capacity(current_keys.len());
let mut replaced = false;
let mut cursor = 0;
while let Some(offset) = current_keys[cursor..].find('@') {
let start = cursor + offset;
next.push_str(&current_keys[cursor..start]);
let mut end = start + 1;
while let Some(&byte) = current_keys.as_bytes().get(end) {
if byte.is_ascii_whitespace() || matches!(byte, b'(' | b')' | b',' | b'@') {
break;
}
end_index += 1;
end += 1;
}

let alias_key = &current_keys[start_index + 1..end_index];
if alias_key.is_empty() {
// A bare `@` (trailing, or right before a delimiter) is literal.
next_keys.push('@');
last_index = start_index + 1;
if end == start + 1 {
next.push('@');
cursor = end;
continue;
}
match aliases.get(alias_key) {
Some(value) => {
next_keys.push_str(value);
made_replacement = true;
}
None => return Err(format!("Undefined alias: {}", alias_key)),
let name = &current_keys[start + 1..end];
if Self::is_sticky_profile_reference(&current_keys, start) {
next.push_str(&current_keys[start..end]);
} else if let Some(value) = aliases.get(name) {
next.push_str(value);
replaced = true;
} else {
return Err(format!("Undefined alias: {name}"));
}
last_index = end_index;
cursor = end;
}

next_keys.push_str(&current_keys[last_index..]);

iterations += 1;
if iterations >= MAX_ALIAS_RESOLUTION_DEPTH {
return Err(format!(
"Alias resolution exceeded maximum depth ({}), potential infinite loop detected in '{}'",
MAX_ALIAS_RESOLUTION_DEPTH, keys
));
next.push_str(&current_keys[cursor..]);
if !replaced {
return Ok(current_keys);
}
current_keys = next;
}

if !made_replacement {
break;
}
Err(format!(
"Alias resolution exceeded maximum depth ({}), potential infinite loop detected in '{}'",
MAX_ALIAS_RESOLUTION_DEPTH, keys
))
}

current_keys = next_keys;
fn is_sticky_profile_reference(input: &str, at: usize) -> bool {
let Some(previous) = input[..at].bytes().rev().find(|byte| !byte.is_ascii_whitespace()) else {
return false;
};
if previous != b',' {
return false;
}

Ok(current_keys)
let bytes = input.as_bytes();
let mut depth = 0usize;
for index in (0..at).rev() {
match bytes[index] {
b')' => depth += 1,
b'(' if depth > 0 => depth -= 1,
b'(' => {
let mut name_start = index;
while name_start > 0 && bytes[name_start - 1].is_ascii_alphabetic() {
name_start -= 1;
}
return matches!(
input[name_start..index].to_ascii_lowercase().as_str(),
"sk" | "osm" | "osl"
);
}
_ => {}
}
}
false
}

/// Reconstruct an action string from a parsed pair, resolving every named
Expand Down
Loading