diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index 5b69af996..b78d38c71 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -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 diff --git a/docs/docs/main/docs/configuration/layout.md b/docs/docs/main/docs/configuration/layout.md index 8668db753..ff947941a 100644 --- a/docs/docs/main/docs/configuration/layout.md +++ b/docs/docs/main/docs/configuration/layout.md @@ -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, )` — 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, )` — 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, )`, 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). @@ -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`: diff --git a/docs/docs/main/docs/configuration/rmk_config.md b/docs/docs/main/docs/configuration/rmk_config.md index 45bde3d13..842638cbc 100644 --- a/docs/docs/main/docs/configuration/rmk_config.md +++ b/docs/docs/main/docs/configuration/rmk_config.md @@ -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. @@ -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 diff --git a/docs/docs/main/docs/development/rynk_protocol.md b/docs/docs/main/docs/development/rynk_protocol.md index 4f765947a..bfb934fc8 100644 --- a/docs/docs/main/docs/development/rynk_protocol.md +++ b/docs/docs/main/docs/development/rynk_protocol.md @@ -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: diff --git a/rmk-config/src/keymap.pest b/rmk-config/src/keymap.pest index 80be012dd..6c713ff22 100644 --- a/rmk-config/src/keymap.pest +++ b/rmk-config/src/keymap.pest @@ -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+ } @@ -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 ~ ")" } @@ -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 ~ ")" } @@ -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 @@ -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 ~ ")" } @@ -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. diff --git a/rmk-config/src/keymap.rs b/rmk-config/src/keymap.rs index bc6a6ec91..2414f12c5 100644 --- a/rmk-config/src/keymap.rs +++ b/rmk-config/src/keymap.rs @@ -205,63 +205,77 @@ impl KeyboardTomlConfig { fn alias_resolver(keys: &str, aliases: &HashMap) -> Result { 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(¤t_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(¤t_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 = ¤t_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 = ¤t_keys[start + 1..end]; + if Self::is_sticky_profile_reference(¤t_keys, start) { + next.push_str(¤t_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(¤t_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(¤t_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 diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index d858da563..6a4562dc4 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -238,8 +238,24 @@ impl KeyboardTomlConfig { self.rmk.auto_mouse_layer_max_num.get_or_insert(0); } } + + pub(crate) fn sticky_key_profile_capacity(&self) -> usize { + self.rmk + .sticky_key_profile_max_num + .unwrap_or_else(|| DEFAULT_STICKY_KEY_PROFILE_MAX_NUM.max(self.configured_sticky_key_profile_count())) + } + + pub(crate) fn configured_sticky_key_profile_count(&self) -> usize { + self.behavior + .as_ref() + .and_then(|behavior| behavior.sticky_key.as_ref()) + .map(|sticky_key| sticky_key.profiles.len()) + .unwrap_or_default() + } } +const DEFAULT_STICKY_KEY_PROFILE_MAX_NUM: usize = 4; + /// Keyboard constants configuration for performance and hardware limits #[serde_inline_default] #[derive(Clone, Debug, Deserialize)] @@ -270,6 +286,9 @@ pub(crate) struct RmkConstantsConfig { #[serde_inline_default(16)] #[serde(deserialize_with = "check_morse_profile_max_num")] pub morse_profile_max_num: usize, + /// Capacity of the named Sticky Key profile table (maximum 255). + #[serde(default, deserialize_with = "check_sticky_key_profile_max_num")] + pub sticky_key_profile_max_num: Option, /// Maximum number of patterns a morse key can handle #[serde_inline_default(8)] #[serde(deserialize_with = "check_max_patterns_per_key")] @@ -351,6 +370,20 @@ where Ok(value) } +fn check_sticky_key_profile_max_num<'de, D>(deserializer: D) -> Result, D::Error> +where + D: de::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + if value.is_some_and(|value| value > u8::MAX as usize) { + return Err(de::Error::custom(format!( + "sticky_key_profile_max_num must be between 0 and 255, got {}", + value.unwrap() + ))); + } + Ok(value) +} + fn check_max_patterns_per_key<'de, D>(deserializer: D) -> Result where D: de::Deserializer<'de>, @@ -388,6 +421,7 @@ impl Default for RmkConstantsConfig { fork_max_num: 8, morse_max_num: 8, morse_profile_max_num: 16, + sticky_key_profile_max_num: None, max_patterns_per_key: 8, macro_space_size: 256, debounce_time: 20, @@ -753,9 +787,34 @@ pub(crate) struct BehaviorConfig { pub macros: Option, pub fork: Option, pub morse: Option, + pub sticky_key: Option, pub auto_mouse_layer: Option>, } +/// Default Sticky Key settings and named profile overrides. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StickyKeyConfig { + pub timeout: Option, + pub activate_on_keypress: Option, + pub release_after_hold: Option, + pub max_repeat: Option, + pub release_mode: Option, + #[serde(default)] + pub profiles: HashMap, +} + +/// Per-profile Sticky Key overrides. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StickyKeyProfile { + pub timeout: Option, + pub activate_on_keypress: Option, + pub release_after_hold: Option, + pub max_repeat: Option, + pub release_mode: Option, +} + /// Configurations for auto mouse layer /// /// When motion is detected from a pointing device (e.g. PMW3610), the @@ -1031,7 +1090,7 @@ pub struct SerialConfig { /// Duration in milliseconds #[derive(Clone, Debug, Deserialize)] -pub(crate) struct DurationMillis(#[serde(deserialize_with = "parse_duration_millis")] pub u64); +pub struct DurationMillis(#[serde(deserialize_with = "parse_duration_millis")] pub u64); const fn default_true() -> bool { true diff --git a/rmk-config/src/resolved/behavior.rs b/rmk-config/src/resolved/behavior.rs index 5f57221d2..98ef7fdad 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -1,5 +1,95 @@ use std::collections::HashMap; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StickyKeyReleaseMode { + pub other_key_press: bool, + pub other_key_release: bool, + pub layer_enter: bool, + pub layer_exit: bool, + pub double_tap: bool, +} + +impl StickyKeyReleaseMode { + pub const OTHER_KEY_PRESS: Self = Self { + other_key_press: true, + ..Self::default_const() + }; + pub const OTHER_KEY_RELEASE: Self = Self { + other_key_release: true, + ..Self::default_const() + }; + pub const LAYER_ENTER: Self = Self { + layer_enter: true, + ..Self::default_const() + }; + pub const LAYER_EXIT: Self = Self { + layer_exit: true, + ..Self::default_const() + }; + pub const DOUBLE_TAP: Self = Self { + double_tap: true, + ..Self::default_const() + }; + + const fn default_const() -> Self { + Self { + other_key_press: false, + other_key_release: false, + layer_enter: false, + layer_exit: false, + double_tap: false, + } + } + + pub const fn into_bits(self) -> u8 { + (self.other_key_press as u8) + | ((self.other_key_release as u8) << 1) + | ((self.layer_enter as u8) << 2) + | ((self.layer_exit as u8) << 3) + | ((self.double_tap as u8) << 4) + } + + pub fn parse(value: &str) -> Result { + let mut mode = Self::default(); + for part in value.split('|').map(str::trim).filter(|part| !part.is_empty()) { + match part { + "other_key_press" => mode.other_key_press = true, + "other_key_release" => mode.other_key_release = true, + "layer_enter" => mode.layer_enter = true, + "layer_exit" => mode.layer_exit = true, + "double_tap" => mode.double_tap = true, + _ => { + return Err(format!( + "unknown Sticky Key release_mode `{part}`; expected other_key_press, other_key_release, layer_enter, layer_exit, or double_tap" + )); + } + } + } + if mode == Self::default() { + return Err("Sticky Key release_mode must contain at least one trigger".to_string()); + } + Ok(mode) + } +} + +pub struct StickyKeyConfig { + pub timeout_ms: Option, + pub activate_on_keypress: Option, + pub release_after_hold_ms: Option, + pub max_repeat: Option, + pub release_mode: Option, + pub profiles: HashMap, +} + +#[derive(Clone, Debug, Default)] +pub struct StickyKeyProfile { + pub timeout_ms: Option, + pub activate_on_keypress: Option, + pub release_after_hold_ms: Option, + pub max_repeat: Option, + pub release_mode: Option, +} + /// Resolved behavioral configuration. pub struct Behavior { pub tri_layer: Option<[u8; 3]>, @@ -9,6 +99,7 @@ pub struct Behavior { pub macros: Option, pub forks: Option, pub morse: Option, + pub sticky_key: Option, pub auto_mouse_layer: Vec, } @@ -237,6 +328,42 @@ impl crate::KeyboardTomlConfig { )); } + let sticky_key = toml_behavior + .sticky_key + .map(|sticky| -> Result { + let parse_profile = |profile: crate::StickyKeyProfile| -> Result { + Ok(StickyKeyProfile { + timeout_ms: profile.timeout.map(|timeout| timeout.0), + activate_on_keypress: profile.activate_on_keypress, + release_after_hold_ms: profile.release_after_hold.map(|duration| duration.0), + max_repeat: profile.max_repeat, + release_mode: profile + .release_mode + .as_deref() + .map(StickyKeyReleaseMode::parse) + .transpose()?, + }) + }; + let profiles = sticky + .profiles + .into_iter() + .map(|(name, profile)| parse_profile(profile).map(|profile| (name, profile))) + .collect::, _>>()?; + Ok(StickyKeyConfig { + timeout_ms: sticky.timeout.map(|timeout| timeout.0), + activate_on_keypress: sticky.activate_on_keypress, + release_after_hold_ms: sticky.release_after_hold.map(|duration| duration.0), + max_repeat: sticky.max_repeat, + release_mode: sticky + .release_mode + .as_deref() + .map(StickyKeyReleaseMode::parse) + .transpose()?, + profiles, + }) + }) + .transpose()?; + let auto_mouse_layer = toml_behavior .auto_mouse_layer .unwrap_or_default() @@ -260,6 +387,7 @@ impl crate::KeyboardTomlConfig { macros, forks, morse, + sticky_key, auto_mouse_layer, }) } @@ -295,6 +423,7 @@ mod tests { use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; + use super::StickyKeyReleaseMode; use crate::KeyboardTomlConfig; #[test] @@ -381,4 +510,38 @@ hold_timeout = "300ms" }; assert!(err.contains("morse_profile_max_num"), "unexpected error: {err}"); } + + #[test] + fn sticky_profiles_and_release_modes_resolve() { + let config: KeyboardTomlConfig = toml::from_str( + r#" +[behavior.sticky_key] +release_after_hold = "300ms" +release_mode = "other_key_release | layer_exit | double_tap" + +[behavior.sticky_key.profiles.alt_tab] +timeout = "5s" +release_after_hold = "500ms" +release_mode = "other_key_press | layer_enter" +"#, + ) + .unwrap(); + + let sticky = config.behavior().unwrap().sticky_key.unwrap(); + assert_eq!(sticky.timeout_ms, None); + assert_eq!(sticky.release_after_hold_ms, Some(300)); + assert_eq!( + sticky.release_mode.unwrap().into_bits(), + StickyKeyReleaseMode::OTHER_KEY_RELEASE.into_bits() + | StickyKeyReleaseMode::LAYER_EXIT.into_bits() + | StickyKeyReleaseMode::DOUBLE_TAP.into_bits() + ); + let alt_tab = &sticky.profiles["alt_tab"]; + assert_eq!(alt_tab.timeout_ms, Some(5000)); + assert_eq!(alt_tab.release_after_hold_ms, Some(500)); + assert_eq!( + alt_tab.release_mode.unwrap().into_bits(), + StickyKeyReleaseMode::OTHER_KEY_PRESS.into_bits() | StickyKeyReleaseMode::LAYER_ENTER.into_bits() + ); + } } diff --git a/rmk-config/src/resolved/build_constants.rs b/rmk-config/src/resolved/build_constants.rs index 7c4ef3f54..88975086b 100644 --- a/rmk-config/src/resolved/build_constants.rs +++ b/rmk-config/src/resolved/build_constants.rs @@ -36,6 +36,7 @@ pub struct BuildConstants { pub fork_max_num: usize, pub morse_max_num: usize, pub morse_profile_max_num: usize, + pub sticky_key_profile_max_num: usize, pub max_patterns_per_key: usize, pub macro_space_size: usize, pub debounce_time: u16, @@ -121,6 +122,12 @@ impl crate::KeyboardTomlConfig { // Declarations live in subscriber_default.toml. apply_feature_subscriber_bumps(&mut events, active_features); + let auto_mouse_layers = self + .behavior + .as_ref() + .and_then(|behavior| behavior.auto_mouse_layer.as_deref()) + .unwrap_or_default(); + // Only validate passkey settings when the build will emit passkey constants. let passkey = if active_features.contains(&"passkey_entry") { self.ble.as_ref().map(resolve_passkey_enabled).transpose()? @@ -154,15 +161,27 @@ impl crate::KeyboardTomlConfig { let auto_mouse_layer_max_num = rmk .auto_mouse_layer_max_num .unwrap_or(crate::resolved::behavior::DEFAULT_AUTO_MOUSE_LAYER_MAX_NUM); - if let Some(entries) = self.behavior.as_ref().and_then(|b| b.auto_mouse_layer.as_ref()) { - if entries.len() > auto_mouse_layer_max_num { + let sticky_key_profile_max_num = self.sticky_key_profile_capacity(); + let sticky_key_profile_count = self.configured_sticky_key_profile_count(); + if sticky_key_profile_count > u8::MAX as usize { + return Err(format!( + "behavior.sticky_key.profiles defines {sticky_key_profile_count} profiles, but at most 255 named profiles are supported" + )); + } + if sticky_key_profile_count > sticky_key_profile_max_num { + return Err(format!( + "behavior.sticky_key.profiles defines {sticky_key_profile_count} profiles, but `[rmk] sticky_key_profile_max_num` is {sticky_key_profile_max_num}. Raise it in keyboard.toml" + )); + } + if !auto_mouse_layers.is_empty() { + if auto_mouse_layers.len() > auto_mouse_layer_max_num { return Err(format!( "number of [[behavior.auto_mouse_layer]] entries ({}) exceeds auto_mouse_layer_max_num ({})", - entries.len(), + auto_mouse_layers.len(), auto_mouse_layer_max_num )); } - let uses_action_event = entries + let uses_action_event = auto_mouse_layers .iter() .any(|e| e.deactivate_on_key == Some(true) || e.reset_timeout_on_key == Some(true)); if uses_action_event && events.iter().any(|e| e.name == "action" && e.subs == 0) { @@ -185,6 +204,7 @@ impl crate::KeyboardTomlConfig { fork_max_num: rmk.fork_max_num, morse_max_num: rmk.morse_max_num, morse_profile_max_num: rmk.morse_profile_max_num, + sticky_key_profile_max_num, max_patterns_per_key: rmk.max_patterns_per_key, macro_space_size: rmk.macro_space_size, debounce_time: rmk.debounce_time, @@ -328,6 +348,37 @@ mod tests { assert!(parse(toml).build_constants(&[]).is_ok()); } + #[test] + fn sticky_profile_capacity_is_derived_and_validated() { + let derived = parse( + "[behavior.sticky_key.profiles.one]\n[behavior.sticky_key.profiles.two]\n[behavior.sticky_key.profiles.three]\n[behavior.sticky_key.profiles.four]\n[behavior.sticky_key.profiles.five]\n", + ) + .build_constants(&[]) + .unwrap(); + assert_eq!(derived.sticky_key_profile_max_num, 5); + + let too_small = "[rmk]\nsticky_key_profile_max_num = 0\n\n[behavior.sticky_key.profiles.named]\n"; + let err = match parse(too_small).build_constants(&[]) { + Ok(_) => panic!("expected sticky profile capacity validation failure"), + Err(err) => err, + }; + assert!(err.contains("sticky_key_profile_max_num")); + } + + #[test] + fn sticky_profile_count_preserves_the_default_profile_sentinel() { + let mut toml = String::new(); + for index in 0..=u8::MAX { + toml.push_str(&format!("[behavior.sticky_key.profiles.profile_{index}]\n")); + } + + let err = match parse(&toml).build_constants(&[]) { + Ok(_) => panic!("expected sticky profile count validation failure"), + Err(err) => err, + }; + assert!(err.contains("at most 255 named profiles")); + } + #[test] fn deactivate_on_key_without_action_subs_is_rejected() { let toml = "[[behavior.auto_mouse_layer]]\ntarget_layer = 1\ndeactivate_on_key = true\n"; diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index f7175195d..58be25c6a 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use proc_macro2::{Ident, TokenStream as TokenStream2}; use quote::{format_ident, quote}; use rmk_config::resolved::KEYCODE_ALIAS; -use rmk_config::resolved::behavior::MorseProfile; +use rmk_config::resolved::behavior::{MorseProfile, StickyKeyProfile}; use strum::VariantNames; struct ModifierCombinationMacro { @@ -166,6 +166,31 @@ pub(crate) fn expand_profile_name( } } +pub(crate) fn sorted_sticky_profile_names( + profiles: Option<&HashMap>, +) -> Vec { + let mut names: Vec = profiles + .map(|profiles| profiles.keys().cloned().collect()) + .unwrap_or_default(); + names.sort(); + names +} + +fn sticky_profile_index( + name: Option<&str>, + profiles: &Option>, +) -> TokenStream2 { + let Some(name) = name else { + return quote! { ::core::primitive::u8::MAX }; + }; + let names = sorted_sticky_profile_names(profiles.as_ref()); + let Some(index) = names.iter().position(|candidate| candidate == name) else { + panic!("\n❌ `{name}` profile name is not found in behavior.sticky_key.profiles"); + }; + let index = index as u8; + quote! { #index } +} + /// Split `s` on commas that are *not* nested inside parentheses. /// /// Each piece is trimmed and empty pieces are dropped. This lets an argument @@ -205,6 +230,94 @@ fn strip_call(s: &str) -> &str { }) } +fn parse_sticky_action( + key: &str, + sticky_profiles: &Option>, +) -> Option { + let lower = key.to_lowercase(); + let (inner, alias) = if lower.starts_with("osm(") { + (strip_call(key).trim(), Some("modifier")) + } else if lower.starts_with("osl(") { + (strip_call(key).trim(), Some("layer")) + } else if lower.starts_with("sk(") { + (strip_call(key).trim(), None) + } else { + return None; + }; + + let args = split_top_level(inner); + let profile_name = args + .last() + .filter(|part| part.starts_with('@')) + .map(|part| part.trim_start_matches('@')); + let profile = sticky_profile_index(profile_name, sticky_profiles); + let action_args = if profile_name.is_some() { + &args[..args.len() - 1] + } else { + &args[..] + }; + let action = action_args.join(", "); + + let action = match alias { + Some("modifier") => { + let modifiers = parse_modifiers(&action); + if modifiers.is_empty() { + panic!("\n❌ keyboard.toml: OSM(modifier) is not valid"); + } + quote! { ::rmk::types::action::Action::Modifier(#modifiers) } + } + Some("layer") => { + let layer = action.parse::().unwrap(); + quote! { ::rmk::types::action::Action::LayerOn(#layer) } + } + None if action.to_lowercase().starts_with("mo(") => { + let layer = parse_layer(&action); + quote! { ::rmk::types::action::Action::LayerOn(#layer) } + } + None if action.contains('[') => { + let start = action.find('[').unwrap(); + let end = action + .find(']') + .unwrap_or_else(|| panic!("\n❌ keyboard.toml: SK has unclosed '['")); + let key_ident = get_key_with_alias( + action[..start] + .trim() + .trim_end_matches(',') + .trim() + .to_string(), + ); + let after = action[end + 1..].trim_start_matches(',').trim(); + if !after.is_empty() { + panic!( + "\n❌ keyboard.toml: the 5-positional SK(...) form is removed; use SK(key, [mods])." + ); + } + let modifiers = if action[start + 1..end].trim().is_empty() { + ModifierCombinationMacro::new() + } else { + parse_modifiers(&action[start + 1..end]) + }; + quote! { ::rmk::types::action::Action::KeyWithModifier(::rmk::types::keycode::HidKeyCode::#key_ident, #modifiers) } + } + None => { + if action.contains('(') { + panic!( + "\n❌ keyboard.toml: SK only supports MO(n) as its layer shape (got `{action}`)." + ); + } + let modifiers = parse_modifiers(&action); + if modifiers.is_empty() { + panic!("\n❌ keyboard.toml: SK(modifier) is not valid"); + } + quote! { ::rmk::types::action::Action::Modifier(#modifiers) } + } + _ => unreachable!(), + }; + Some(quote! { + ::rmk::types::action::KeyAction::Sticky(#action, #profile) + }) +} + /// Parse a single "action expression" into an [`rmk_types::action::Action`] token stream. /// /// These forms each map to exactly one `Action`, so they may appear both at the @@ -212,10 +325,17 @@ fn strip_call(s: &str) -> &str { /// tap/hold slots of `MT`/`TH`/`LT`. Composite forms (`MT`/`TH`/`LT`/`TT`/`TD`) /// and `Transparent` are *not* handled here — they only exist at the top level /// and are dispatched by [`parse_key`]. -pub(crate) fn parse_action(key: &str) -> TokenStream2 { +fn parse_action_with_profiles( + key: &str, + sticky_profiles: &Option>, +) -> TokenStream2 { let lower = key.to_lowercase(); - if lower == "no" { + if parse_sticky_action(key, sticky_profiles).is_some() { + panic!( + "\n❌ keyboard.toml: Sticky Keys are key actions and cannot be nested inside MT/TH/LT" + ); + } else if lower == "no" { return quote! { ::rmk::types::action::Action::No }; } else if lower.starts_with("mod(") { let modifiers = parse_modifiers(strip_call(key)); @@ -245,14 +365,6 @@ pub(crate) fn parse_action(key: &str) -> TokenStream2 { #modifiers, ) }; - } else if lower.starts_with("osm(") { - let modifiers = parse_modifiers(strip_call(key)); - if modifiers.is_empty() { - panic!( - "\n\u{274c} keyboard.toml: modifier in OSM(modifier) is not valid! Please check the documentation: https://rmk.rs/docs/features/configuration/layout.html" - ); - } - return quote! { ::rmk::types::action::Action::OneShotModifier(#modifiers) }; } else if lower.starts_with("lm(") { let keys = split_top_level(strip_call(key)); if keys.len() != 2 { @@ -271,9 +383,6 @@ pub(crate) fn parse_action(key: &str) -> TokenStream2 { } else if lower.starts_with("mo(") { let layer = parse_layer(key); return quote! { ::rmk::types::action::Action::LayerOn(#layer) }; - } else if lower.starts_with("osl(") { - let layer = parse_layer(key); - return quote! { ::rmk::types::action::Action::OneShotLayer(#layer) }; } else if lower.starts_with("tg(") { let layer = parse_layer(key); return quote! { ::rmk::types::action::Action::LayerToggle(#layer) }; @@ -378,6 +487,10 @@ pub(crate) fn parse_action(key: &str) -> TokenStream2 { } } +pub(crate) fn parse_action(key: &str) -> TokenStream2 { + parse_action_with_profiles(key, &None) +} + /// Parse the key string at a single position into a [`KeyAction`] token stream. /// /// Composite tap/hold/morse forms (`MT`/`TH`/`LT`/`TT`/`TD`) and the @@ -388,6 +501,7 @@ pub(crate) fn parse_action(key: &str) -> TokenStream2 { pub(crate) fn parse_key( key: String, profiles: &Option>, + sticky_profiles: &Option>, ) -> TokenStream2 { if !key.is_empty() && (key.trim_start_matches("_").is_empty() || key.to_lowercase() == "trns") { return quote! { ::rmk::a!(Transparent) }; @@ -397,6 +511,10 @@ pub(crate) fn parse_key( let lower = key.to_lowercase(); + if let Some(action) = parse_sticky_action(&key, sticky_profiles) { + return action; + } + if lower.starts_with("mt(") { let keys = split_top_level(strip_call(&key)); if keys.len() < 2 || keys.len() > 3 { @@ -404,7 +522,7 @@ pub(crate) fn parse_key( "\n\u{274c} keyboard.toml: MT(key, modifier) invalid, please check the documentation: https://rmk.rs/docs/features/configuration/layout.html" ); } - let tap = parse_action(&keys[0]); + let tap = parse_action_with_profiles(&keys[0], sticky_profiles); let modifiers = parse_modifiers(&keys[1]); if modifiers.is_empty() { panic!( @@ -422,8 +540,8 @@ pub(crate) fn parse_key( "\n\u{274c} keyboard.toml: TH(key_tap, key_hold) invalid, please check the documentation: https://rmk.rs/docs/features/configuration/layout.html" ); } - let tap = parse_action(&keys[0]); - let hold = parse_action(&keys[1]); + let tap = parse_action_with_profiles(&keys[0], sticky_profiles); + let hold = parse_action_with_profiles(&keys[1], sticky_profiles); let profile = morse_profile(keys.get(2), profiles); quote! { ::rmk::types::action::KeyAction::TapHold(#tap, #hold, #profile) } } else if lower.starts_with("lt(") { @@ -434,7 +552,7 @@ pub(crate) fn parse_key( ); } let layer = keys[0].parse::().unwrap(); - let tap = parse_action(&keys[1]); + let tap = parse_action_with_profiles(&keys[1], sticky_profiles); let profile = morse_profile(keys.get(2), profiles); quote! { ::rmk::types::action::KeyAction::TapHold(#tap, ::rmk::types::action::Action::LayerOn(#layer), #profile) @@ -446,7 +564,7 @@ pub(crate) fn parse_key( let index = strip_call(&key).trim().parse::().unwrap(); quote! { ::rmk::types::action::KeyAction::Morse(#index) } } else { - let action = parse_action(&key); + let action = parse_action_with_profiles(&key, sticky_profiles); quote! { ::rmk::types::action::KeyAction::Single(#action) } } } @@ -525,7 +643,7 @@ mod tests { use rmk_config::resolved::behavior::MorseProfile; fn expand(key: &str) -> String { - parse_key(key.to_string(), &None).to_string() + parse_key(key.to_string(), &None, &None).to_string() } fn profile(enable_flow_tap: Option) -> MorseProfile { @@ -631,7 +749,7 @@ mod tests { ); assert!(squash(&expand("WM(C,LCtrl)")).contains("Action::KeyWithModifier")); assert!(squash(&expand("MOD(LCtrl | LAlt | LGui)")).contains("Action::Modifier")); - assert!(squash(&expand("OSM(LShift)")).contains("Action::OneShotModifier")); + assert!(squash(&expand("OSM(LShift)")).contains("KeyAction::Sticky")); } #[test] diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 847297522..b73715926 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -6,12 +6,12 @@ use quote::{format_ident, quote}; use rmk_config::resolved::Behavior; use rmk_config::resolved::behavior::{ AutoMouseLayer, Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, - MorseProfile, OneShot, + MorseProfile, OneShot, StickyKeyProfile, }; use super::action_parser::{ as_hid_keycode, expand_profile, expand_profile_name, get_key_with_alias, parse_action, - parse_key, sorted_profile_names, + parse_key, sorted_profile_names, sorted_sticky_profile_names, }; use super::feature::{get_rmk_features, is_feature_enabled}; @@ -69,9 +69,88 @@ fn expand_one_shot_modifiers(one_shot_modifiers: &Option) -> proc_macro } } +fn expand_sticky_key_profile( + profile: &StickyKeyProfile, + fallback: &StickyKeyProfile, +) -> proc_macro2::TokenStream { + let timeout = profile.timeout_ms.or(fallback.timeout_ms).unwrap_or(1000); + let activate_on_keypress = profile + .activate_on_keypress + .or(fallback.activate_on_keypress) + .unwrap_or(false); + let release_after_hold = match profile + .release_after_hold_ms + .or(fallback.release_after_hold_ms) + { + Some(duration) => { + quote! { ::rmk::config::StickyKeyHoldDuration::from_duration(::rmk::embassy_time::Duration::from_millis(#duration)) } + } + None => quote! { ::rmk::config::StickyKeyHoldDuration::DISABLED }, + }; + let max_repeat = profile.max_repeat.or(fallback.max_repeat).unwrap_or(0); + let release_mode = match profile.release_mode.or(fallback.release_mode) { + Some(mode) => { + let bits = mode.into_bits(); + quote! { ::core::option::Option::Some(::rmk::types::sticky_key::StickyKeyReleaseMode::from_bits(#bits)) } + } + None => quote! { ::core::option::Option::None }, + }; + quote! { + ::rmk::config::StickyKeyProfile { + timeout: ::rmk::embassy_time::Duration::from_millis(#timeout), + activate_on_keypress: #activate_on_keypress, + release_after_hold: #release_after_hold, + max_repeat: #max_repeat, + release_mode: #release_mode, + } + } +} + +fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { + let default = behavior + .sticky_key + .as_ref() + .map(|sticky| StickyKeyProfile { + timeout_ms: sticky.timeout_ms.or(behavior.one_shot_timeout_ms), + activate_on_keypress: sticky.activate_on_keypress.or(behavior + .one_shot_modifiers + .as_ref() + .and_then(|one_shot| one_shot.activate_on_keypress)), + release_after_hold_ms: sticky.release_after_hold_ms, + max_repeat: sticky.max_repeat, + release_mode: sticky.release_mode, + }) + .unwrap_or_else(|| StickyKeyProfile { + timeout_ms: behavior.one_shot_timeout_ms, + activate_on_keypress: behavior + .one_shot_modifiers + .as_ref() + .and_then(|one_shot| one_shot.activate_on_keypress), + ..Default::default() + }); + let default_profile = expand_sticky_key_profile(&default, &StickyKeyProfile::default()); + let profiles = behavior + .sticky_key + .as_ref() + .map(|sticky| { + sorted_sticky_profile_names(Some(&sticky.profiles)) + .into_iter() + .map(|name| expand_sticky_key_profile(&sticky.profiles[&name], &default)) + .collect::>() + }) + .unwrap_or_default(); + quote! { + ::rmk::config::StickyKeyConfig { + default_profile: #default_profile, + profiles: ::rmk::heapless::Vec::from_iter([#(#profiles),*]), + } + } +} + fn expand_morse_action_pair( action_pair: &MorseActionPair, profiles: &Option>, + sticky_profiles: &Option>, ) -> proc_macro2::TokenStream { let mut pattern = 0b1u16; for ch in action_pair.pattern.chars() { @@ -84,18 +163,19 @@ fn expand_morse_action_pair( _ => {} } } - let action = parse_key(action_pair.action.to_owned(), profiles); + let action = parse_key(action_pair.action.to_owned(), profiles, sticky_profiles); quote! { (rmk::types::morse::MorsePattern::from_u16(#pattern), #action.to_action()) } } fn expand_morse_actions( actions: &[MorseActionPair], profiles: &Option>, + sticky_profiles: &Option>, ) -> proc_macro2::TokenStream { if !actions.is_empty() { let action_pair_def = actions .iter() - .map(|action_pair| expand_morse_action_pair(action_pair, profiles)); + .map(|action_pair| expand_morse_action_pair(action_pair, profiles, sticky_profiles)); quote! { actions: ::rmk::heapless::LinearMap::from_iter([#(#action_pair_def),*]), } @@ -104,7 +184,10 @@ fn expand_morse_actions( } } -fn expand_morse(morse: &Option) -> proc_macro2::TokenStream { +fn expand_morse( + morse: &Option, + sticky_profiles: &Option>, +) -> proc_macro2::TokenStream { if let Some(config) = morse { let enable_flow_tap = config.enable_flow_tap; let enable_flow_tap_token = quote! { enable_flow_tap: #enable_flow_tap, }; @@ -120,7 +203,7 @@ fn expand_morse(morse: &Option) -> proc_macro2::TokenStream { } else { Some(config.profiles.clone()) }; - let morses = expand_morses(&config.morses, &profiles_ref); + let morses = expand_morses(&config.morses, &profiles_ref, sticky_profiles); // Interned morse profile table, in the same sorted-name order used by // `morse_profile` when it emits per-key indices. The pushes can't overflow: @@ -163,6 +246,7 @@ fn expand_morse(morse: &Option) -> proc_macro2::TokenStream { fn expand_combos( combos: &Option, profiles: &Option>, + sticky_profiles: &Option>, ) -> proc_macro2::TokenStream { let default = quote! { ::core::default::Default::default() }; match combos { @@ -190,8 +274,8 @@ fn expand_combos( } } else { let combos_def = combos.combos.iter().map(|combo| { - let actions = combo.actions.iter().map(|a| parse_key(a.to_owned(), profiles)); - let output = parse_key(combo.output.to_owned(), profiles); + let actions = combo.actions.iter().map(|a| parse_key(a.to_owned(), profiles, sticky_profiles)); + let output = parse_key(combo.output.to_owned(), profiles, sticky_profiles); let layer = match combo.layer { Some(layer) => quote! { ::core::option::Option::Some(#layer) }, None => quote! { ::core::option::Option::None }, @@ -289,6 +373,7 @@ fn expand_macros(macros: &Option) -> proc_macro2::TokenStream { fn expand_morses( morses: &[MorseKey], profiles: &Option>, + sticky_profiles: &Option>, ) -> proc_macro2::TokenStream { if morses.is_empty() { return quote! {}; @@ -306,7 +391,7 @@ fn expand_morses( panic!("\n❌ keyboard.toml: `morse_actions` cannot be used together with `tap_actions`, `hold_actions`, `tap`, `hold`, `hold_after_tap`, or `double_tap`. Please check the documentation: https://rmk.rs/docs/features/configuration/behavior.html#morse"); } - let actions_def = expand_morse_actions(morse_actions, profiles); + let actions_def = expand_morse_actions(morse_actions, profiles, sticky_profiles); quote! { ::rmk::types::morse::Morse { @@ -325,7 +410,7 @@ fn expand_morses( let tap_actions_def = match &morse.tap_actions { Some(tap_actions) => { let actions = tap_actions.iter().map(|action| { - let parsed_action = parse_key(action.clone(), profiles); + let parsed_action = parse_key(action.clone(), profiles, sticky_profiles); quote! { #parsed_action } }); quote! { ::rmk::heapless::Vec::from_iter([#(#actions.to_action()),*]) } @@ -336,7 +421,7 @@ fn expand_morses( let hold_actions_def = match &morse.hold_actions { Some(hold_actions) => { let actions = hold_actions.iter().map(|action| { - let parsed_action = parse_key(action.clone(), profiles); + let parsed_action = parse_key(action.clone(), profiles, sticky_profiles); quote! { #parsed_action } }); quote! { ::rmk::heapless::Vec::from_iter([#(#actions.to_action()),*]) } @@ -352,10 +437,10 @@ fn expand_morses( ) } } else { - let tap = parse_key(morse.tap.clone().unwrap_or_else(|| "No".to_string()), profiles); - let hold = parse_key(morse.hold.clone().unwrap_or_else(|| "No".to_string()), profiles); - let hold_after_tap = parse_key(morse.hold_after_tap.clone().unwrap_or_else(|| "No".to_string()), profiles); - let double_tap = parse_key(morse.double_tap.clone().unwrap_or_else(|| "No".to_string()), profiles); + let tap = parse_key(morse.tap.clone().unwrap_or_else(|| "No".to_string()), profiles, sticky_profiles); + let hold = parse_key(morse.hold.clone().unwrap_or_else(|| "No".to_string()), profiles, sticky_profiles); + let hold_after_tap = parse_key(morse.hold_after_tap.clone().unwrap_or_else(|| "No".to_string()), profiles, sticky_profiles); + let double_tap = parse_key(morse.double_tap.clone().unwrap_or_else(|| "No".to_string()), profiles, sticky_profiles); quote! { ::rmk::types::morse::Morse::new_from_vial( @@ -500,14 +585,15 @@ fn parse_state_combination(states_str: &str) -> StateBitsMacro { fn expand_forks( forks: &Option, profiles: &Option>, + sticky_profiles: &Option>, ) -> proc_macro2::TokenStream { let default = quote! { ::core::default::Default::default() }; match forks { Some(forks) => { let forks_def = forks.forks.iter().map(|fork| { - let trigger = parse_key(fork.trigger.to_owned(), profiles); - let negative_output = parse_key(fork.negative_output.to_owned(), profiles); - let positive_output = parse_key(fork.positive_output.to_owned(), profiles); + let trigger = parse_key(fork.trigger.to_owned(), profiles, sticky_profiles); + let negative_output = parse_key(fork.negative_output.to_owned(), profiles, sticky_profiles); + let positive_output = parse_key(fork.positive_output.to_owned(), profiles, sticky_profiles); let match_any = fork.match_any.as_ref().map(|s| parse_state_combination(s)).unwrap_or_default(); let match_none = fork.match_none.as_ref().map(|s| parse_state_combination(s)).unwrap_or_default(); let kept = fork.kept_modifiers.as_ref().map(|s| parse_state_combination(s)).unwrap_or_default(); @@ -578,14 +664,20 @@ pub(crate) fn expand_behavior_config(behavior: &Behavior) -> proc_macro2::TokenS .as_ref() .map(|m| m.profiles.clone()) .filter(|p| !p.is_empty()); + let sticky_profiles = behavior + .sticky_key + .as_ref() + .map(|sticky| sticky.profiles.clone()) + .filter(|profiles| !profiles.is_empty()); let tri_layer = expand_tri_layer(&behavior.tri_layer); let one_shot = expand_one_shot(&behavior.one_shot_timeout_ms); let one_shot_modifiers = expand_one_shot_modifiers(&behavior.one_shot_modifiers); - let combos = expand_combos(&behavior.combos, &profiles); + let combos = expand_combos(&behavior.combos, &profiles, &sticky_profiles); let macros = expand_macros(&behavior.macros); - let forks = expand_forks(&behavior.forks, &profiles); - let morse = expand_morse(&behavior.morse); + let forks = expand_forks(&behavior.forks, &profiles, &sticky_profiles); + let morse = expand_morse(&behavior.morse, &sticky_profiles); + let sticky_key = expand_sticky_key(behavior); let auto_mouse_layer = expand_auto_mouse_layer(&behavior.auto_mouse_layer); quote! { @@ -600,8 +692,40 @@ pub(crate) fn expand_behavior_config(behavior: &Behavior) -> proc_macro2::TokenS keyboard_macros: #macros, mouse_key: ::rmk::config::MouseKeyConfig::default(), tap: ::rmk::config::TapConfig::default(), + sticky_key: #sticky_key, auto_mouse_layer: #auto_mouse_layer, ..Default::default() }; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sticky_key_profile_inherits_and_overrides_keyup_hold_release() { + let fallback = StickyKeyProfile { + release_after_hold_ms: Some(300), + ..Default::default() + }; + let inherited = + expand_sticky_key_profile(&StickyKeyProfile::default(), &fallback).to_string(); + assert!(inherited.contains("StickyKeyHoldDuration :: from_duration")); + assert!(inherited.contains("Duration :: from_millis (300")); + + let override_duration = StickyKeyProfile { + release_after_hold_ms: Some(500), + ..Default::default() + }; + let overridden = expand_sticky_key_profile(&override_duration, &fallback).to_string(); + assert!(overridden.contains("Duration :: from_millis (500")); + + let disabled = + expand_sticky_key_profile(&StickyKeyProfile::default(), &StickyKeyProfile::default()) + .to_string(); + assert!(disabled.contains( + "release_after_hold : :: rmk :: config :: StickyKeyHoldDuration :: DISABLED" + )); + } +} diff --git a/rmk-macro/src/codegen/keymap.rs b/rmk-macro/src/codegen/keymap.rs index 5389f0759..204cd6825 100644 --- a/rmk-macro/src/codegen/keymap.rs +++ b/rmk-macro/src/codegen/keymap.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use proc_macro2::TokenStream as TokenStream2; use quote::quote; -use rmk_config::resolved::behavior::MorseProfile; +use rmk_config::resolved::behavior::{MorseProfile, StickyKeyProfile}; use rmk_config::resolved::{Behavior, Keymap}; use super::action_parser::parse_key; @@ -16,6 +16,11 @@ pub(crate) fn expand_default_keymap(keymap: &Keymap, behavior: &Behavior) -> Tok .as_ref() .map(|m| m.profiles.clone()) .filter(|p| !p.is_empty()); + let sticky_profiles: Option> = behavior + .sticky_key + .as_ref() + .map(|sticky| sticky.profiles.clone()) + .filter(|profiles| !profiles.is_empty()); let num_encoder = keymap.num_encoder; @@ -23,7 +28,7 @@ pub(crate) fn expand_default_keymap(keymap: &Keymap, behavior: &Behavior) -> Tok let mut encoder_map = vec![]; for layer in &keymap.keymap { - layers.push(expand_layer(layer.clone(), &profiles)); + layers.push(expand_layer(layer.clone(), &profiles, &sticky_profiles)); } for encoder_layer in &keymap.encoder_map { @@ -31,6 +36,7 @@ pub(crate) fn expand_default_keymap(keymap: &Keymap, behavior: &Behavior) -> Tok encoder_layer.clone(), num_encoder, &profiles, + &sticky_profiles, )); } encoder_map.resize( @@ -53,19 +59,24 @@ pub(crate) fn expand_default_keymap(keymap: &Keymap, behavior: &Behavior) -> Tok pub(crate) fn expand_layer( layer: Vec>, profiles: &Option>, + sticky_profiles: &Option>, ) -> TokenStream2 { let mut rows = vec![]; for row in layer { - rows.push(expand_row(row, profiles)); + rows.push(expand_row(row, profiles, sticky_profiles)); } quote! { [#(#rows), *] } } /// Expand a row for keymap -fn expand_row(row: Vec, profiles: &Option>) -> TokenStream2 { +fn expand_row( + row: Vec, + profiles: &Option>, + sticky_profiles: &Option>, +) -> TokenStream2 { let mut keys = vec![]; for key in row { - keys.push(parse_key(key, profiles)); + keys.push(parse_key(key, profiles, sticky_profiles)); } quote! { [#(#keys), *] } } @@ -75,12 +86,13 @@ pub(crate) fn expand_encoder_layer( encoder_layer: Vec<[String; 2]>, num_encoder: usize, profiles: &Option>, + sticky_profiles: &Option>, ) -> TokenStream2 { let mut encoders = vec![]; for encoder in encoder_layer { - let cw_action = parse_key(encoder[0].clone(), profiles); - let ccw_action = parse_key(encoder[1].clone(), profiles); + let cw_action = parse_key(encoder[0].clone(), profiles, sticky_profiles); + let ccw_action = parse_key(encoder[1].clone(), profiles, sticky_profiles); encoders.push(quote! { ::rmk::encoder!(#cw_action, #ccw_action) }); } diff --git a/rmk-macro/src/codegen/simulator.rs b/rmk-macro/src/codegen/simulator.rs index 0e98f6997..5c755ec1c 100644 --- a/rmk-macro/src/codegen/simulator.rs +++ b/rmk-macro/src/codegen/simulator.rs @@ -273,11 +273,15 @@ fn expand_builder( rmk_config: Option, ) -> TokenStream2 { let profiles = behavior.morse.as_ref().map(|m| m.profiles.clone()); + let sticky_profiles = behavior + .sticky_key + .as_ref() + .map(|sticky| sticky.profiles.clone()); let rows = keymap.rows as usize; let cols = keymap.cols as usize; let layers = keymap.layers as usize; let layer_tokens = keymap.keymap.iter(); - let layer_tokens = layer_tokens.map(|l| expand_layer(l.clone(), &profiles)); + let layer_tokens = layer_tokens.map(|l| expand_layer(l.clone(), &profiles, &sticky_profiles)); let num_encoder = keymap.num_encoder; let encoder_call = (num_encoder > 0).then(|| { @@ -286,7 +290,7 @@ fn expand_builder( encoder_map.resize(layers, Vec::new()); let encoder_layers = encoder_map .into_iter() - .map(|e| expand_encoder_layer(e, num_encoder, &profiles)); + .map(|e| expand_encoder_layer(e, num_encoder, &profiles, &sticky_profiles)); quote! { .encoders([#(#encoder_layers),*]) } }); diff --git a/rmk-types/build.rs b/rmk-types/build.rs index 697166c32..da14bc089 100644 --- a/rmk-types/build.rs +++ b/rmk-types/build.rs @@ -76,6 +76,10 @@ fn generate_constants(bc: &BuildConstants, config: &KeyboardTomlConfig) -> Strin "pub const MORSE_PROFILE_MAX_NUM: usize = {};", bc.morse_profile_max_num )); + lines.push(format!( + "pub const STICKY_KEY_PROFILE_MAX_NUM: usize = {};", + bc.sticky_key_profile_max_num + )); lines.push(format!( "pub const AUTO_MOUSE_LAYER_MAX_NUM: usize = {};", bc.auto_mouse_layer_max_num diff --git a/rmk-types/src/action/key_action.rs b/rmk-types/src/action/key_action.rs index 6f84e8a36..7d408975f 100644 --- a/rmk-types/src/action/key_action.rs +++ b/rmk-types/src/action/key_action.rs @@ -27,6 +27,9 @@ pub enum KeyAction { TapHold(Action, Action, u8), /// Morse action, references a morse configuration by index. Morse(u8), + /// Sticky activation of an action. The `u8` indexes the Sticky Key profile + /// table; `u8::MAX` selects the default profile. + Sticky(Action, u8), } impl KeyAction { @@ -50,8 +53,9 @@ impl KeyAction { } } -/// Combo and fork trigger matching compares key actions by their "identity" — -/// the tap/hold actions — ignoring the profile-table index. +/// Combo and fork trigger matching compares tap/hold actions by their logical +/// actions while ignoring their timing-profile index. Sticky profiles remain +/// part of identity because they define release behavior, not only timing. /// /// This is intentional: a combo or fork may store a trigger with one profile /// index, but if the user later rebinds the key's profile, the trigger should @@ -66,6 +70,7 @@ impl PartialEq for KeyAction { (KeyAction::Tap(a), KeyAction::Tap(b)) => a == b, (KeyAction::TapHold(a, b, _), KeyAction::TapHold(c, d, _)) => a == c && b == d, (KeyAction::Morse(a), KeyAction::Morse(b)) => a == b, + (KeyAction::Sticky(a, profile_a), KeyAction::Sticky(b, profile_b)) => a == b && profile_a == profile_b, _ => false, } } diff --git a/rmk-types/src/action/mod.rs b/rmk-types/src/action/mod.rs index f50f9b10b..233054a35 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -61,12 +61,6 @@ pub enum Action { TriLayerUpper, /// Triggers the Macro at the 'index'. TriggerMacro(u8), - /// Oneshot layer, keep the layer active until the next key is triggered. - OneShotLayer(u8), - /// Oneshot modifier, keep the modifier active until the next key is triggered. - OneShotModifier(ModifierCombination), - /// Oneshot key, keep the key active until the next key is triggered. - OneShotKey(HidKeyCode), /// Actions for controlling lights Light(LightAction), /// Actions for controlling the keyboard @@ -84,4 +78,9 @@ pub enum Action { /// sent to the host as a vendor HID report. #[cfg(feature = "steno")] Steno(StenoKey), + /// Reserved so later variants keep identical postcard discriminants when + /// the `steno` feature is disabled. + #[cfg(not(feature = "steno"))] + #[doc(hidden)] + ReservedSteno, } diff --git a/rmk-types/src/lib.rs b/rmk-types/src/lib.rs index a89065883..0a900d853 100644 --- a/rmk-types/src/lib.rs +++ b/rmk-types/src/lib.rs @@ -53,6 +53,7 @@ pub mod mouse_button; pub mod protocol; #[cfg(feature = "steno")] pub mod steno; +pub mod sticky_key; /// Compute the maximum varint-encoded length for a given max value. /// Mirrors `postcard`'s internal `varint_size`. diff --git a/rmk-types/src/protocol/rynk/payload/keymap.rs b/rmk-types/src/protocol/rynk/payload/keymap.rs index 608cddb9f..77e748e27 100644 --- a/rmk-types/src/protocol/rynk/payload/keymap.rs +++ b/rmk-types/src/protocol/rynk/payload/keymap.rs @@ -128,7 +128,7 @@ mod tests { /// tests makes `assert_max_size_bound` exercise both the per-element /// and the length-prefix dimensions of the bound. fn worst_key_action() -> KeyAction { - let action = Action::KeyWithModifier(HidKeyCode::A, ModifierCombination::new()); + let action = Action::KeyWithModifier(HidKeyCode::A, ModifierCombination::LCTRL); KeyAction::TapHold(action, action, u8::MAX) } diff --git a/rmk-types/src/protocol/rynk/payload/system.rs b/rmk-types/src/protocol/rynk/payload/system.rs index 112dbe664..1e0b38f6c 100644 --- a/rmk-types/src/protocol/rynk/payload/system.rs +++ b/rmk-types/src/protocol/rynk/payload/system.rs @@ -20,8 +20,9 @@ pub struct ProtocolVersion { impl ProtocolVersion { /// Current protocol version for this firmware release. - /// Now the protocol is still being developed, so the version is v0.1 - pub const CURRENT: Self = Self { major: 0, minor: 1 }; + /// Version 1.0 introduces `KeyAction::Sticky` and removes the legacy + /// one-shot `Action` variants. + pub const CURRENT: Self = Self { major: 1, minor: 0 }; } /// Device capabilities discovered during the connection handshake. diff --git a/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap b/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap index ff7ed6bdf..f0c825810 100644 --- a/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap +++ b/rmk-types/src/protocol/rynk/snapshots/wire_frames.snap @@ -44,7 +44,7 @@ GetMorse reply Ok(Morse{TAP->Key(A)}) 04 01 04 GetMorse request 0 04 01 04 01 01 00 GetSleepState reply Ok(true) 04 06 08 01 02 01 00 GetSleepState request () 04 06 08 01 00 -GetVersion reply Ok(CURRENT) 02 01 02 01 01 02 01 00 +GetVersion reply Ok(CURRENT) 02 01 02 01 02 01 01 00 GetVersion request () 02 01 02 01 00 GetWpm reply Ok(42) 04 05 08 01 02 2a 00 GetWpm request () 04 05 08 01 00 diff --git a/rmk-types/src/protocol/rynk/snapshots/wire_values.snap b/rmk-types/src/protocol/rynk/snapshots/wire_values.snap index 073c10d80..9a4720e4b 100644 --- a/rmk-types/src/protocol/rynk/snapshots/wire_values.snap +++ b/rmk-types/src/protocol/rynk/snapshots/wire_values.snap @@ -6,86 +6,84 @@ # UPDATE_SNAPSHOTS=1 cargo test -p rmk-types --features rynk wire_values # Format: